{"owner":"max-sixty","repo":"worktrunk","hasSkills":true,"hasMcp":false,"mcpConfig":null,"found":["CLAUDE.md"],"skills":{"CLAUDE.md":"# Worktrunk Development Guidelines\n\n## Quick Start\n\n```bash\ncargo run -- hook pre-merge --yes   # all tests + lints (runs automatically in wt merge)\n```\n\nClaude Code web: run `task setup-web` first. Test commands, isolation, and coverage investigation: `tests/CLAUDE.md`.\nCodex Cloud: use the checksum-gated setup in `scripts/codex-cloud/README.md`.\n\n## Project Status\n\nMaturing mode: a growing user base, so balance clean design with compatibility.\n\n- External-interface breaks need justification (a real improvement, not cleanup); prefer deprecation warnings over silent breaks.\n- **Protected interfaces:** config file format (`wt.toml`, user config) and CLI flags/arguments. Everything else (internal APIs, output formatting, log locations) is flexible.\n- No Rust library compatibility concerns (CLI tool only).\n- MSRV: latest stable − 1, bumped during weekly tend maintenance (`running-tend` skill).\n\n## Terminology\n\nUse consistently in docs, help text, and code comments:\n\n- **main worktree** — the original git directory (from clone/init); bare repos have none\n- **linked worktree** — created via `git worktree add` (git's term)\n- **primary worktree** — the \"home\" worktree: main worktree for normal repos, default-branch worktree for bare repos\n- **default branch** — the branch (main, master, …), not \"main branch\"\n- **target** — destination for merge/rebase/push (\"merge target\"). Never use \"target\" for worktrees; say \"worktree\"\n\n## Skills\n\nLoad relevant skills before starting; reload when scope changes mid-session. Project-local skills in `.claude/skills/`:\n\n- `writing-user-outputs` — before editing code that calls `warning_message`, `hint_message`, `error_message`, `info_message`, `eprintln`, `println`, or otherwise produces user-visible strings (CLI help, progress UI, snapshots).\n- `running-tend` — operating in CI or writing tend workflows.\n- `release` — cutting a release.\n\n## Worktree Model\n\n- Worktrees are **addressed by branch name**, with a worktree's own path as an alias — resolved branch-first by `Repository::resolve_worktree`, the one canonicalizer every worktree-naming argument routes through. A path is not a second addressing scheme: it names what a branch cannot (a detached worktree, one of two checkouts of a branch). So document arguments as taking a branch, state the path alias once rather than per argument, and give a new argument the canonicalizer rather than its own rule.\n- Each worktree maps to **exactly one branch**.\n- **Never retarget an existing worktree** to a different branch; create/switch/remove instead. (Sole exception: `wt step promote`, experimental, exchanges branches between two worktrees.)\n\n## Documentation\n\nBehavior changes require doc updates. `src/cli/mod.rs` (`after_long_help` plus clap attributes) is the PRIMARY SOURCE for command pages; their rendered mirrors in `docs/content/` and `skills/worktrunk/reference/` are generated, as is all of `plugins/worktrunk/skills/` — but both directories also hold hand-edited primaries (non-command docs in `docs/content/`, skill-only pages like `shell-integration.md` in the reference dir), so check which file is primary in the sync taxonomy before editing. Ask: \"does `--help` still describe what the code does?\" `cargo test --test integration test_docs_are_in_sync` checks doc sync; editing help text (`after_long_help`, `about`, arg docs) also changes the rendered `--help` snapshots, which that test leaves untouched — `cargo insta test --accept --test integration -- test_help` regenerates them (the pre-merge hook runs both). Sync taxonomy, help-text authoring (three render contexts, link text, config-TOML blocks): `docs/CLAUDE.md`.\n\n## Plugin Layout\n\nPer-tool layout and path resolution (Claude/Codex/Gemini), the convention-only Claude manifest, the Codex inline-hooks rationale, the generated plugin-skills mirror, the accepted `wt-switch-create` tradeoff, and `test_plugin_layout_is_consolidated`: `plugins/worktrunk/CLAUDE.md`.\n\n## Data Safety\n\nNever risk data loss without explicit user consent. A failed command that preserves data beats a \"successful\" one that silently destroys work.\n\n- **Prefer failure over silent loss** — if an operation might destroy untracked files, uncommitted changes, or user data, fail with an error.\n- **Explicit consent for destructive ops** — force-removing data (e.g. `--force` on remove) requires the user to explicitly request it.\n- **No implicit destructive side effects** — never silently delete/overwrite as a side effect of an unrelated operation; make cleanup a separate explicit action the user chooses.\n- **Favor the failing variant on races** — `git reset --keep` (fails if tracked files were modified) over `--hard`; `git checkout --merge` over `--force`. If no safer variant exists, document the risk inline.\n- **Time-of-check vs time-of-use** — be conservative when there's a gap between the safety check and the operation. `wt merge` verifies clean before rebasing, but files could appear before cleanup — don't force-remove during cleanup.\n- **Replace files, never truncate them** — `fs::write` truncates before it writes, so a crash mid-write leaves the file empty. Every write to a file worktrunk can't put back (rc files, shell wrappers, `config.toml`, `approvals.toml`, another tool's `settings.json`) goes through `utils::write_atomically`, which renames a sibling temp file over the target; the spec on that function covers symlinks, mode, and what a rename costs. Regenerable content (the cache, the `-vv` diagnostic report) keeps the plain write.\n\nThese stop where git's own protections stop, and matching git is deliberate in each case. The named spec says why:\n\n- `wt merge` and `wt step push` overwrite an ignored file in the destination worktree whose path the incoming commits track, exactly as a `git merge` run there would (`src/commands/worktree/push.rs`).\n- Removal's final dirty-worktree gate is answered by the fsmonitor daemon under `core.fsmonitor`, exactly as `git worktree remove`'s own gate is (`src/git/remove.rs`).\n\nFull inventory: FAQ [What files does Worktrunk create?](docs/content/faq.md#what-files-does-worktrunk-create) and [What can Worktrunk delete?](docs/content/faq.md#what-can-worktrunk-delete). Review new code that changes this surface against those sections.\n\n## Command Execution Principles\n\n### All Commands Through `shell_exec::Cmd`\n\nEvery external command goes through `shell_exec::Cmd` for consistent debug logging (`$ git status [worktree-name]`) and `[wt-trace]` timing. Never call `cmd.output()` directly. For git, prefer `Repository::run_command()` (wraps `Cmd` with worktree context). `Cmd` has four execution modes — `run` (capture), `stream` (inherit stdio), `delayed_stream` (buffer then stream to stderr, for slow ops like `git worktree add`), and `pipe_into` (two-stage pipe). Pipe stdin via `.stdin_bytes(...)`.\n\n```rust\nCmd::new(\"git\").args([\"status\", \"--porcelain\"]).current_dir(&wt).context(\"worktree-name\").run()?;\nCmd::new(\"gh\").args([\"pr\", \"list\"]).run()?;  // no context for standalone tools\n```\n\n**The `[wt-trace]` command record has one emitter: `CommandTrace` in `src/trace/emit.rs`.** The grammar lives there too (don't hand-write `log::debug!(\"[wt-trace] …\")`). `CommandTrace::{complete,fail}` are the only callers of the private `command_completed`/`command_errored` writers, so a subprocess is either traced through the guard or produces no command record. Most spawns get this for free via `Cmd`. A few spawn sites have I/O shapes `Cmd` can't model and construct a `CommandTrace` directly: the concurrent-command runner (`output/concurrent.rs`), pipeline steps (`commands/run_pipeline.rs`), `wt step tether`, and the fsmonitor daemon launch. **Any new spawn site that runs an in-process command must construct a `CommandTrace` (start it just before spawn; `complete(success)` after wait, `fail(err)` on spawn/wait error)** — otherwise the command shows up as an unattributed gap in `wt-perf timeline`. The guard is `#[must_use]` and trips a debug-build assertion if dropped unresolved, so a forgotten `complete`/`fail` fails tests rather than silently going untraced. Detached background children (`commands/process.rs`) and interactive helpers (pagers, shell probes) are intentionally untraced — they outlive the invocation or aren't part of its timeline.\n\n### Git-Discovery Env Vars Follow Who Chose the Cwd\n\nGit resolves `GIT_DIR`/`GIT_WORK_TREE` (and the rest of `INHERITED_GIT_PATH_VARS`) before walking up from the cwd, so an inherited value silently overrides a child's working directory. **Any spawn site that relocates a user command into a `wt`-chosen worktree — hooks, `wt step for-each`, the `--execute` no-integration fallback — must scrub these vars** (`Cmd::scrub_git_discovery_env` or `scrub_git_discovery_env_vars`); children running in the user's own context (aliases, `commit.generation`) and `wt`'s internal git plumbing keep the inherited context (absolutized). Full site classification and rationale: `scrub_git_discovery_env_vars` in `src/shell_exec.rs`.\n\n### Real-time Output Streaming\n\nStream command output line-by-line rather than buffering. Responsiveness is a priority.\n\n### Structured Output Over Error-Message Parsing\n\nPrefer exit codes / `--porcelain` / `--json` over parsing human-readable messages, which break on locale, version, and rewording changes. `git merge-base` exit codes encode meaning (0 found, 1 no common ancestor, 128 invalid ref) — branch on `status.code()`, not message text.\n\n| Tool | Fragile | Structured |\n|------|---------|------------|\n| `git diff` | `--stat` (localized) | `--numstat`, `--shortstat` (`(+)`/`(-)` hardcoded) |\n| `git status` | default | `--porcelain=v2` |\n| `git merge-base` | error messages | exit codes |\n| `gh` / `glab` | default | `--json` |\n\nWhen no structured alternative exists, document the fragility inline.\n\n### Immutable Ids Over List Positions\n\n`stash@{0}` names a position in a list any process can reorder, so a handle captured before a mutation window and used after it can resolve to a different object — restoring the target worktree's autostash by position after `git push` silently restored a concurrent writer's entry and reported success. Capture the immutable id instead (`git stash list --format=%H`, `git stash create`, `rev-parse`) and act on that; where an operation accepts only a positional selector, re-derive it from something stable immediately beforehand. An index into a collection `wt` owns is a different thing — this is about namespaces other processes can mutate. The strongest form is not to enter the shared namespace at all: the autostash this rule came from was later deleted outright, replaced by a two-tree merge that leaves the target worktree's changes in place (`advance_target` in `src/commands/worktree/push.rs`).\n\n### Network Access\n\nworktrunk is local-first: the network is touched only when the user asked for it, and only where reaching the wire directly serves that request. **One detection helper is exempt:** the *first* `Repository::default_branch()` per repo may fall through to `git ls-remote`; the result caches in `worktrunk.default-branch` and every later call is local. The query is bounded by `REMOTE_DETECTION_TIMEOUT` — nothing in git bounds it, and an unreachable host costs ~127 s per address on Linux — and a query that hits the bound falls back to local inference *without* caching it, so an outage can't make a guess permanent. No other detection helper may add a similar fallback.\n\nWhy: silent \"lookup\" paths that walk to the wire (alias dispatch, hook context build, recovery) stall commands the user wouldn't expect to do network work, worst on a fresh clone. The `default_branch()` bootstrap keeps a fresh clone usable while bounding the exception to one helper firing at most once per repo.\n\n**Network never blocks the first write.** Fast output to the terminal is the priority (Real-time Output Streaming, above): every command paints from local data first, then network-derived detail streams in progressively behind it. A command that can't render its first frame until `gh` or `git fetch` returns is the failure mode, worst on a fresh clone or a slow link. Before adding an accessor that could reach the wire (`gh`, `glab`, `git fetch`, `git ls-remote`, HTTP), confirm it renders progressively and never gates the first paint. A synchronous hot path like a shell prompt is stricter: it must not reach the wire at all, even progressively. `wt list statusline` is not such a path despite running on every prompt, because Claude Code consumes its output asynchronously.\n\n**The picker is the most forgiving home for network work, because its lifetime is bounded by the user, not the job.** It paints immediately, the user browses, and a slow forge call streams into the rows whenever it arrives; if the user picks first, the picker's exit cancels the unfinished request (`shell_exec::cancel_background_commands`), so its latency never costs anything. A run-to-completion command is less forgiving: `wt list` renders progressively but still cannot *finish* until every task returns, so a slow `gh` call extends the command the user is waiting on. Prefer the picker for live forge data, and fetch it there progressively.\n\nWhat currently reaches the wire:\n\n- `wt list --full`, `wt list statusline` — CI status; also plain `wt list` (table) when `[list] columns` names `ci`, which forces the column (and its fetch) on without `--full`. `--format json` plans off `--full` alone, so a display setting can't send a machine-readable call to a forge\n- `wt switch` (interactive picker, no target) — per-row CI status, primed from the local cache then fetched live and streamed into the rows; once a row's CI fetch surfaces an open PR/MR, a per-row background `gh pr view <n> --json comments` (`glab api …/notes` on GitLab) fills that row's `comments` preview tab — the same fetch a `--prs` row makes, spawned once per row from `progressive_handler` (see `picker::prs::spawn_comments_fetch`). The `comments` tab is the only PR data fetched lazily here; `pr` rides the CI call and `log` is the local `git log`\n- generating a branch summary with a `commit.generation` command\n- generating a commit message with a `commit.generation` command\n- `wt switch pr:<n>`, `wt switch mr:<n>` — host API to resolve the PR/MR, then `git fetch` of its branch\n- `wt switch --prs` — one `gh pr list` / `glab mr list` to populate the interactive picker (streamed in after the frame paints), then a per-row background `gh pr view <n> --json comments` (`glab api …/notes` on GitLab) to fill each row's `comments` preview tab, plus a `gh pr view <n> --json commits` / `glab api …/commits` for the `log` tab **only when the head commit isn't already local** — a `--prs` row whose `headRefOid`/`sha` resolves in the object store renders the `log` tab from a local `git log` with no network (off the pool, once per row when the rows land — see `picker::prs::spawn_pr_previews`)\n- `wt config show --full` — version check against GitHub\n- the first `Repository::default_branch()` per repo — `git ls-remote` (above)\n\n### Signal Handling: Ctrl-C Cancels the Current Command\n\nWhen a child process exits from a signal (SIGINT, SIGTERM), every loop in the foreground execution path MUST abort rather than continue to the next iteration. This applies to worktree loops (`wt step for-each`), hook pipelines, alias steps, concurrent groups, and any future code running multiple child processes in sequence.\n\nWhy: wt installs a `signal_hook` SIGINT/SIGTERM handler so it can forward signals to child process groups before exiting cleanly. As a side effect wt itself does not die from the user's Ctrl-C — only the current child does. Without this policy a single Ctrl-C against `wt merge` would charge through the remaining hook steps, with `FailureStrategy::Warn` silently swallowing each interrupt.\n\n- Signal-derived child exits surface structurally: stream mode (`Cmd::stream`) as `WorktrunkError::ChildProcessExited { signal: Some(sig), .. }`, capture mode (`Cmd::run`) as `CommandError { signal: Some(sig), .. }`. These fields are the structured channel — never sniff `code >= 128` or parse error messages.\n- Detect via `err.interrupt_signal()` (the `worktrunk::git::ErrorExt` trait). When it returns `Some(signal)`, propagate as `WorktrunkError::Interrupted { signal, hint }` and break the loop. `Interrupted` exits `128 + signal` (130 SIGINT, 143 SIGTERM) and renders once, at exit, per shell convention: silent for SIGINT (the terminal echoed `^C`), `Terminated` for SIGTERM — the line the shell would print if wt weren't trapping the signal. `hint` carries an optional recovery line for state the interrupt left behind (e.g. a mid-rebase worktree).\n- In capture mode only SIGINT/SIGTERM classify as interrupts. Capture children get no forwarding or escalation, and their captured output would be discarded by the silent exit — so a child killed by any other signal (a crash, an OOM kill) surfaces as a visible error instead. A capture child with a `Cmd::timeout` is the one the tty broadcast doesn't reach: it runs in its own process group so expiry can tear down its whole tree (`run_with_timeout_impl`), which is what makes the bound bound anything, so a Ctrl-C during one waits out the remaining timeout. Stream mode counts any signal: output already streamed to the terminal, and user-initiated kills are normalized upstream to the originating SIGINT/SIGTERM (`seen_signal` in `shell_exec`, the concurrent runner's originating-signal override).\n- The check happens **before** any `FailureStrategy` branch — Warn must NOT swallow signal-derived errors.\n- `handle_command_error` in `src/commands/command_executor.rs` enforces this for hook and alias pipelines (foreground and concurrent groups); `for_each.rs` enforces it for the worktree loop. New code that loops over child processes calls `.interrupt_signal()` on per-iteration errors and breaks.\n\n### Project Commands Run Only After Approval\n\n**Policy:** project-defined commands (`pre-*` / `post-*` hooks, `[aliases]`, `--execute` bodies from project config) are arbitrary code shipped in a repo the user may have just cloned, so they run only after the approval system (`Approvals` plus `approve_command_batch` / `approve_or_skip` in `src/commands/command_approval.rs`) clears them. Never build a code path that runs project commands without that gate. A context that can't prompt (a TUI mid-render, a background recovery path) consults the approval state read-only and runs only the already-approved subset: `commands::picker::do_removal` builds the plan via `HookPlan::approve_readonly` (no prompt).\n\n**Why:** the gate is the only thing between `git clone && wt switch` and a `post-switch` hook running `curl … | sh`. A \"we already validated the operation, so run the hooks too\" shortcut turns every command that touches project config into remote code execution.\n\n**Implementation:** the operation-driven hooks (`pre-merge`, `post-merge`, `pre-remove`, `post-remove`, `post-switch`, `pre-start`, `post-start`) are gated *before* a state mutation and run *after* it, so a second config read could select an unapproved command. `src/commands/hook_plan.rs` closes this structurally: each gate (`wt remove` / `wt merge` / `wt step prune` / `wt switch`) selects the command set once into an immutable `ApprovedHookPlan` (`HookPlan::approve`); the executor consumes only that value via `execute_planned_hook` / `register_planned` and holds no `ProjectConfig` to re-derive from, so re-selection is a compile error, not a review check. An empty plan (`--no-hooks`, declined, or no project config) runs nothing. The adjacent hooks with no gate→exec mutation (`pre-commit`, `post-commit`, `pre-switch`, `wt hook <type>`, aliases) still resolve config at invocation via `execute_hook` / `HookAnnouncer::register`. See `src/commands/hook_plan.rs` and the `commands::hooks` module spec.\n\n## Hook Output Logs\n\n`.git/wt/logs/` layout — per-branch and repo-wide log paths, plus the `sanitize_for_filename` filename rule: the `HookLog` spec in `src/commands/process.rs`. The top-level file-vs-directory split that `wt config state` walks: the \"Log layout invariant\" in `src/commands/config/state.rs`.\n\n## Coverage\n\n**`codecov/patch` gates the merge, not the design.** Write the change the design calls for, then deal with the check. A predicted red patch is never a reason to shelve or water down an improvement, and neither the code nor the tests get contorted to move the number. The patch target is `auto` (the base commit's project coverage), so a handful of missed lines flips a small patch red, and a refactor that only relocates existing uncovered lines pulls their misses in without changing behavior.\n\n**NEVER merge a PR with failing `codecov/patch` without explicit user approval.** It is marked \"not required\" in GitHub but still gates merge. On PR heads codecov posts **check runs** (codecov GitHub App), not commit statuses: poll with `gh pr checks <number>` or the check-runs API; the combined-status API (`/commits/<sha>/status`) never shows them, and the check run lands a few minutes after the `code-coverage` job finishes. On failure, close the gap where it's real — write tests, or delete code that's genuinely unused (a specialized error handler where falling through to the general one suffices; never a rarely-reached backstop that's load-bearing). Where it isn't real — misses that predate the change, or a path with no deterministic trigger — push the change and ask before merging, handing over the arithmetic: which lines, why they can't be covered, what the patch percentage comes to. Coverage runs include `--features shell-integration-tests` (CI `code-coverage` and local `task coverage`) — don't dismiss failures by claiming the feature is off. Investigation commands, moved-line false positives, and the \"N functions mismatched\" warning: `tests/CLAUDE.md`.\n\n## Benchmarks & Traces\n\n`cargo bench --bench list <filter>` (Criterion takes a positional substring filter; there's no `--skip`). `cargo run -p wt-perf -- timeline -- <args>` traces one `wt` invocation. Real-repo benchmarks clone rust-lang/rust on first run. Benchmarks run as a standalone scheduled workflow (`.github/workflows/benchmarks.yaml`, daily cron plus `workflow_dispatch`), not on PRs, so they never gate a merge; only `test (linux|macos|windows)` block it. Filter map, expected numbers, and trace queries: `benches/CLAUDE.md`.\n\n## Code Quality\n\n### Use Existing Dependencies\n\nCheck `Cargo.toml` before hand-rolling a utility:\n\n| Need | Use | Not |\n|------|-----|-----|\n| Path normalization | `path_slash::PathExt::to_slash_lossy()` | `.to_string_lossy().replace('\\\\', \"/\")` |\n| Shell escaping | `shell_escape::unix::escape()` | manual quoting |\n| ANSI colors | `color_print::cformat!()` | raw escape codes |\n| Template var detection | `minijinja::undeclared_variables(false)` | regex/substring on `{{ var }}` |\n\n### Other\n\n- **Don't suppress warnings** with `#[allow(dead_code)]` — delete the code or add `// TODO(topic): used by <upcoming work>`.\n- **System docstrings** — complex systems (state machines, cached state, cross-module coordination, non-obvious invalidation) get a module-level spec docstring (purpose, key decisions, contracts, invariants); keep it current. Exemplar: `commands/list/collect/mod.rs`.\n- **No test code in library code** — no `#[cfg(test)]` convenience methods on library types; tests call the real API or define their own helpers.\n- **Multiline strings** — plain literals with real embedded newlines (`r#\"…\"#` to avoid escaping `\"`); never `\\` continuation (silently strips following whitespace) or `concat!()`. Place long constants at module level.\n\n## Error Handling\n\n`anyhow` with context. `bail!` for business-logic errors (dirty worktree, missing branch, invalid state); `.context()` for wrapping I/O and external-command failures. Never `.expect()` / `.unwrap()` in a function returning `Result` — use `?`, `bail!`, or return an error.\n\n## Config Deprecation\n\nAll config deprecation lives in one layer: pre-deserialization TOML migration in `src/config/deprecation.rs`. `migrate_content()` rewrites deprecated patterns into canonical form before serde parses; `check_and_migrate()` reuses it, and additionally detects patterns and emits per-process-deduped warnings (the user materializes migrations via `wt config update`). **Never silently drop an old config key** — that's a silent behavior change for users; migrate it.\n\nEvery deprecation is one row in the `DEPRECATION_RULES` table: a single idempotent function that rewrites the pattern AND returns the `DeprecationKind`s for what it changed — there is no separate detection function, so detection and migration share one predicate and cannot drift. Detection runs the same functions against a scratch copy of the document (progressively, so a rule sees earlier rules' rewrites); the invariant for warning rules is **a warning fires exactly when `wt config update` would change the file**, pinned by `test_warning_fires_iff_update_changes` — add new edge cases to its battery. The row variant decides when the rewrite applies: `Structural` rewrites on every load; `UpdateOnly` only via `wt config update`, for deprecated forms that still work at runtime; `Silent` rewrites on every load with no warning — its function signature has no channel for a kind, which is what scopes the invariant to `Structural` and `UpdateOnly`; `PendingDefault` adopts a default a future release switches — `wt config update` writes the upcoming value (currently `[list] json-schema = 2`), inert while the system config layer defines the key — update-pass only, scoped to the config kind that owns the key, and excluded from load warnings by `is_pending_default`: it satisfies the same iff at the surface that reads the setting, where the `wt list` JSON nag fires exactly when update would write. Table order is both the warning-emission order and the migration order. Each `DeprecationKind` carries its own display payload, so `format_deprecation_warnings()` is one match over the kinds. A config that can't be rewritten safely (a malformed value, an occupied destination key) is left untouched and unwarned — serde's type or unknown-field error is the messaging; an empty deprecated section is also left alone, with no message at all (it contributes no config). Adding a deprecation: (1) one idempotent migrate-and-report function; (2) a `DeprecationKind` variant plus its match arm in `format_deprecation_warnings()`; (3) a `DEPRECATION_RULES` row; (4) for a removed top-level section, add a `DeprecatedSection` to `DEPRECATED_SECTION_KEYS` (canonical key plus display form) so `warn_unknown_fields` defers to the deprecation messaging and suggests the correct config file. A silently-migrated rename (e.g. `pre-create` → `pre-start`) is a `Silent` row with no variant. Renaming a field within a section follows the same shape via a TOML-level rename function (see `migrate_negated_bool`); the struct never needs the old field since migration precedes serde.\n\n## Adding CLI Commands\n\nRecipe, help-text placement, and flag-description conventions: `src/commands/CLAUDE.md`.\n\n## Accessor Function Naming\n\n| Prefix | Returns | Side effects | Absent → | Example |\n|--------|---------|--------------|----------|---------|\n| (bare noun) | `Option<T>` / `T` | none (may cache) | None/default | `config()`, `switch_previous()` |\n| `set_*` | `Result<()>` | writes state | errors | `set_config()` |\n| `require_*` | `Result<T>` | none | errors | `require_branch()` |\n| `fetch_*` | `Result<T>` | network I/O | errors | `fetch_pr_info()` |\n| `load_*` | `Result<T>` | file I/O | errors | `load_project_config()` |\n\nNo `get_*` — bare nouns follow Rust stdlib convention.\n\n## Repository Caching\n\n`Repository` caches read-only values via `Arc<RepoCache>` (cloning shares it). What is and isn't cached, the `list_worktrees()` post-mutation invariant, the two storage patterns, and the in-memory-`RepoCache`-vs-persistent-`sha_cache` decision (cheap-and-hot → in-memory get-or-create; expensive → disk; both → in-memory front over disk back): the `# Caching` section in `src/git/repository/mod.rs`.\n\n## Releases\n\nUse the `release` skill (version bump, changelog, crates.io publish, GitHub release).\n"},"files":{"CLAUDE.md":"# Worktrunk Development Guidelines\n\n## Quick Start\n\n```bash\ncargo run -- hook pre-merge --yes   # all tests + lints (runs automatically in wt merge)\n```\n\nClaude Code web: run `task setup-web` first. Test commands, isolation, and coverage investigation: `tests/CLAUDE.md`.\nCodex Cloud: use the checksum-gated setup in `scripts/codex-cloud/README.md`.\n\n## Project Status\n\nMaturing mode: a growing user base, so balance clean design with compatibility.\n\n- External-interface breaks need justification (a real improvement, not cleanup); prefer deprecation warnings over silent breaks.\n- **Protected interfaces:** config file format (`wt.toml`, user config) and CLI flags/arguments. Everything else (internal APIs, output formatting, log locations) is flexible.\n- No Rust library compatibility concerns (CLI tool only).\n- MSRV: latest stable − 1, bumped during weekly tend maintenance (`running-tend` skill).\n\n## Terminology\n\nUse consistently in docs, help text, and code comments:\n\n- **main worktree** — the original git directory (from clone/init); bare repos have none\n- **linked worktree** — created via `git worktree add` (git's term)\n- **primary worktree** — the \"home\" worktree: main worktree for normal repos, default-branch worktree for bare repos\n- **default branch** — the branch (main, master, …), not \"main branch\"\n- **target** — destination for merge/rebase/push (\"merge target\"). Never use \"target\" for worktrees; say \"worktree\"\n\n## Skills\n\nLoad relevant skills before starting; reload when scope changes mid-session. Project-local skills in `.claude/skills/`:\n\n- `writing-user-outputs` — before editing code that calls `warning_message`, `hint_message`, `error_message`, `info_message`, `eprintln`, `println`, or otherwise produces user-visible strings (CLI help, progress UI, snapshots).\n- `running-tend` — operating in CI or writing tend workflows.\n- `release` — cutting a release.\n\n## Worktree Model\n\n- Worktrees are **addressed by branch name**, with a worktree's own path as an alias — resolved branch-first by `Repository::resolve_worktree`, the one canonicalizer every worktree-naming argument routes through. A path is not a second addressing scheme: it names what a branch cannot (a detached worktree, one of two checkouts of a branch). So document arguments as taking a branch, state the path alias once rather than per argument, and give a new argument the canonicalizer rather than its own rule.\n- Each worktree maps to **exactly one branch**.\n- **Never retarget an existing worktree** to a different branch; create/switch/remove instead. (Sole exception: `wt step promote`, experimental, exchanges branches between two worktrees.)\n\n## Documentation\n\nBehavior changes require doc updates. `src/cli/mod.rs` (`after_long_help` plus clap attributes) is the PRIMARY SOURCE for command pages; their rendered mirrors in `docs/content/` and `skills/worktrunk/reference/` are generated, as is all of `plugins/worktrunk/skills/` — but both directories also hold hand-edited primaries (non-command docs in `docs/content/`, skill-only pages like `shell-integration.md` in the reference dir), so check which file is primary in the sync taxonomy before editing. Ask: \"does `--help` still describe what the code does?\" `cargo test --test integration test_docs_are_in_sync` checks doc sync; editing help text (`after_long_help`, `about`, arg docs) also changes the rendered `--help` snapshots, which that test leaves untouched — `cargo insta test --accept --test integration -- test_help` regenerates them (the pre-merge hook runs both). Sync taxonomy, help-text authoring (three render contexts, link text, config-TOML blocks): `docs/CLAUDE.md`.\n\n## Plugin Layout\n\nPer-tool layout and path resolution (Claude/Codex/Gemini), the convention-only Claude manifest, the Codex inline-hooks rationale, the generated plugin-skills mirror, the accepted `wt-switch-create` tradeoff, and `test_plugin_layout_is_consolidated`: `plugins/worktrunk/CLAUDE.md`.\n\n## Data Safety\n\nNever risk data loss without explicit user consent. A failed command that preserves data beats a \"successful\" one that silently destroys work.\n\n- **Prefer failure over silent loss** — if an operation might destroy untracked files, uncommitted changes, or user data, fail with an error.\n- **Explicit consent for destructive ops** — force-removing data (e.g. `--force` on remove) requires the user to explicitly request it.\n- **No implicit destructive side effects** — never silently delete/overwrite as a side effect of an unrelated operation; make cleanup a separate explicit action the user chooses.\n- **Favor the failing variant on races** — `git reset --keep` (fails if tracked files were modified) over `--hard`; `git checkout --merge` over `--force`. If no safer variant exists, document the risk inline.\n- **Time-of-check vs time-of-use** — be conservative when there's a gap between the safety check and the operation. `wt merge` verifies clean before rebasing, but files could appear before cleanup — don't force-remove during cleanup.\n- **Replace files, never truncate them** — `fs::write` truncates before it writes, so a crash mid-write leaves the file empty. Every write to a file worktrunk can't put back (rc files, shell wrappers, `config.toml`, `approvals.toml`, another tool's `settings.json`) goes through `utils::write_atomically`, which renames a sibling temp file over the target; the spec on that function covers symlinks, mode, and what a rename costs. Regenerable content (the cache, the `-vv` diagnostic report) keeps the plain write.\n\nThese stop where git's own protections stop, and matching git is deliberate in each case. The named spec says why:\n\n- `wt merge` and `wt step push` overwrite an ignored file in the destination worktree whose path the incoming commits track, exactly as a `git merge` run there would (`src/commands/worktree/push.rs`).\n- Removal's final dirty-worktree gate is answered by the fsmonitor daemon under `core.fsmonitor`, exactly as `git worktree remove`'s own gate is (`src/git/remove.rs`).\n\nFull inventory: FAQ [What files does Worktrunk create?](docs/content/faq.md#what-files-does-worktrunk-create) and [What can Worktrunk delete?](docs/content/faq.md#what-can-worktrunk-delete). Review new code that changes this surface against those sections.\n\n## Command Execution Principles\n\n### All Commands Through `shell_exec::Cmd`\n\nEvery external command goes through `shell_exec::Cmd` for consistent debug logging (`$ git status [worktree-name]`) and `[wt-trace]` timing. Never call `cmd.output()` directly. For git, prefer `Repository::run_command()` (wraps `Cmd` with worktree context). `Cmd` has four execution modes — `run` (capture), `stream` (inherit stdio), `delayed_stream` (buffer then stream to stderr, for slow ops like `git worktree add`), and `pipe_into` (two-stage pipe). Pipe stdin via `.stdin_bytes(...)`.\n\n```rust\nCmd::new(\"git\").args([\"status\", \"--porcelain\"]).current_dir(&wt).context(\"worktree-name\").run()?;\nCmd::new(\"gh\").args([\"pr\", \"list\"]).run()?;  // no context for standalone tools\n```\n\n**The `[wt-trace]` command record has one emitter: `CommandTrace` in `src/trace/emit.rs`.** The grammar lives there too (don't hand-write `log::debug!(\"[wt-trace] …\")`). `CommandTrace::{complete,fail}` are the only callers of the private `command_completed`/`command_errored` writers, so a subprocess is either traced through the guard or produces no command record. Most spawns get this for free via `Cmd`. A few spawn sites have I/O shapes `Cmd` can't model and construct a `CommandTrace` directly: the concurrent-command runner (`output/concurrent.rs`), pipeline steps (`commands/run_pipeline.rs`), `wt step tether`, and the fsmonitor daemon launch. **Any new spawn site that runs an in-process command must construct a `CommandTrace` (start it just before spawn; `complete(success)` after wait, `fail(err)` on spawn/wait error)** — otherwise the command shows up as an unattributed gap in `wt-perf timeline`. The guard is `#[must_use]` and trips a debug-build assertion if dropped unresolved, so a forgotten `complete`/`fail` fails tests rather than silently going untraced. Detached background children (`commands/process.rs`) and interactive helpers (pagers, shell probes) are intentionally untraced — they outlive the invocation or aren't part of its timeline.\n\n### Git-Discovery Env Vars Follow Who Chose the Cwd\n\nGit resolves `GIT_DIR`/`GIT_WORK_TREE` (and the rest of `INHERITED_GIT_PATH_VARS`) before walking up from the cwd, so an inherited value silently overrides a child's working directory. **Any spawn site that relocates a user command into a `wt`-chosen worktree — hooks, `wt step for-each`, the `--execute` no-integration fallback — must scrub these vars** (`Cmd::scrub_git_discovery_env` or `scrub_git_discovery_env_vars`); children running in the user's own context (aliases, `commit.generation`) and `wt`'s internal git plumbing keep the inherited context (absolutized). Full site classification and rationale: `scrub_git_discovery_env_vars` in `src/shell_exec.rs`.\n\n### Real-time Output Streaming\n\nStream command output line-by-line rather than buffering. Responsiveness is a priority.\n\n### Structured Output Over Error-Message Parsing\n\nPrefer exit codes / `--porcelain` / `--json` over parsing human-readable messages, which break on locale, version, and rewording changes. `git merge-base` exit codes encode meaning (0 found, 1 no common ancestor, 128 invalid ref) — branch on `status.code()`, not message text.\n\n| Tool | Fragile | Structured |\n|------|---------|------------|\n| `git diff` | `--stat` (localized) | `--numstat`, `--shortstat` (`(+)`/`(-)` hardcoded) |\n| `git status` | default | `--porcelain=v2` |\n| `git merge-base` | error messages | exit codes |\n| `gh` / `glab` | default | `--json` |\n\nWhen no structured alternative exists, document the fragility inline.\n\n### Immutable Ids Over List Positions\n\n`stash@{0}` names a position in a list any process can reorder, so a handle captured before a mutation window and used after it can resolve to a different object — restoring the target worktree's autostash by position after `git push` silently restored a concurrent writer's entry and reported success. Capture the immutable id instead (`git stash list --format=%H`, `git stash create`, `rev-parse`) and act on that; where an operation accepts only a positional selector, re-derive it from something stable immediately beforehand. An index into a collection `wt` owns is a different thing — this is about namespaces other processes can mutate. The strongest form is not to enter the shared namespace at all: the autostash this rule came from was later deleted outright, replaced by a two-tree merge that leaves the target worktree's changes in place (`advance_target` in `src/commands/worktree/push.rs`).\n\n### Network Access\n\nworktrunk is local-first: the network is touched only when the user asked for it, and only where reaching the wire directly serves that request. **One detection helper is exempt:** the *first* `Repository::default_branch()` per repo may fall through to `git ls-remote`; the result caches in `worktrunk.default-branch` and every later call is local. The query is bounded by `REMOTE_DETECTION_TIMEOUT` — nothing in git bounds it, and an unreachable host costs ~127 s per address on Linux — and a query that hits the bound falls back to local inference *without* caching it, so an outage can't make a guess permanent. No other detection helper may add a similar fallback.\n\nWhy: silent \"lookup\" paths that walk to the wire (alias dispatch, hook context build, recovery) stall commands the user wouldn't expect to do network work, worst on a fresh clone. The `default_branch()` bootstrap keeps a fresh clone usable while bounding the exception to one helper firing at most once per repo.\n\n**Network never blocks the first write.** Fast output to the terminal is the priority (Real-time Output Streaming, above): every command paints from local data first, then network-derived detail streams in progressively behind it. A command that can't render its first frame until `gh` or `git fetch` returns is the failure mode, worst on a fresh clone or a slow link. Before adding an accessor that could reach the wire (`gh`, `glab`, `git fetch`, `git ls-remote`, HTTP), confirm it renders progressively and never gates the first paint. A synchronous hot path like a shell prompt is stricter: it must not reach the wire at all, even progressively. `wt list statusline` is not such a path despite running on every prompt, because Claude Code consumes its output asynchronously.\n\n**The picker is the most forgiving home for network work, because its lifetime is bounded by the user, not the job.** It paints immediately, the user browses, and a slow forge call streams into the rows whenever it arrives; if the user picks first, the picker's exit cancels the unfinished request (`shell_exec::cancel_background_commands`), so its latency never costs anything. A run-to-completion command is less forgiving: `wt list` renders progressively but still cannot *finish* until every task returns, so a slow `gh` call extends the command the user is waiting on. Prefer the picker for live forge data, and fetch it there progressively.\n\nWhat currently reaches the wire:\n\n- `wt list --full`, `wt list statusline` — CI status; also plain `wt list` (table) when `[list] columns` names `ci`, which forces the column (and its fetch) on without `--full`. `--format json` plans off `--full` alone, so a display setting can't send a machine-readable call to a forge\n- `wt switch` (interactive picker, no target) — per-row CI status, primed from the local cache then fetched live and streamed into the rows; once a row's CI fetch surfaces an open PR/MR, a per-row background `gh pr view <n> --json comments` (`glab api …/notes` on GitLab) fills that row's `comments` preview tab — the same fetch a `--prs` row makes, spawned once per row from `progressive_handler` (see `picker::prs::spawn_comments_fetch`). The `comments` tab is the only PR data fetched lazily here; `pr` rides the CI call and `log` is the local `git log`\n- generating a branch summary with a `commit.generation` command\n- generating a commit message with a `commit.generation` command\n- `wt switch pr:<n>`, `wt switch mr:<n>` — host API to resolve the PR/MR, then `git fetch` of its branch\n- `wt switch --prs` — one `gh pr list` / `glab mr list` to populate the interactive picker (streamed in after the frame paints), then a per-row background `gh pr view <n> --json comments` (`glab api …/notes` on GitLab) to fill each row's `comments` preview tab, plus a `gh pr view <n> --json commits` / `glab api …/commits` for the `log` tab **only when the head commit isn't already local** — a `--prs` row whose `headRefOid`/`sha` resolves in the object store renders the `log` tab from a local `git log` with no network (off the pool, once per row when the rows land — see `picker::prs::spawn_pr_previews`)\n- `wt config show --full` — version check against GitHub\n- the first `Repository::default_branch()` per repo — `git ls-remote` (above)\n\n### Signal Handling: Ctrl-C Cancels the Current Command\n\nWhen a child process exits from a signal (SIGINT, SIGTERM), every loop in the foreground execution path MUST abort rather than continue to the next iteration. This applies to worktree loops (`wt step for-each`), hook pipelines, alias steps, concurrent groups, and any future code running multiple child processes in sequence.\n\nWhy: wt installs a `signal_hook` SIGINT/SIGTERM handler so it can forward signals to child process groups before exiting cleanly. As a side effect wt itself does not die from the user's Ctrl-C — only the current child does. Without this policy a single Ctrl-C against `wt merge` would charge through the remaining hook steps, with `FailureStrategy::Warn` silently swallowing each interrupt.\n\n- Signal-derived child exits surface structurally: stream mode (`Cmd::stream`) as `WorktrunkError::ChildProcessExited { signal: Some(sig), .. }`, capture mode (`Cmd::run`) as `CommandError { signal: Some(sig), .. }`. These fields are the structured channel — never sniff `code >= 128` or parse error messages.\n- Detect via `err.interrupt_signal()` (the `worktrunk::git::ErrorExt` trait). When it returns `Some(signal)`, propagate as `WorktrunkError::Interrupted { signal, hint }` and break the loop. `Interrupted` exits `128 + signal` (130 SIGINT, 143 SIGTERM) and renders once, at exit, per shell convention: silent for SIGINT (the terminal echoed `^C`), `Terminated` for SIGTERM — the line the shell would print if wt weren't trapping the signal. `hint` carries an optional recovery line for state the interrupt left behind (e.g. a mid-rebase worktree).\n- In capture mode only SIGINT/SIGTERM classify as interrupts. Capture children get no forwarding or escalation, and their captured output would be discarded by the silent exit — so a child killed by any other signal (a crash, an OOM kill) surfaces as a visible error instead. A capture child with a `Cmd::timeout` is the one the tty broadcast doesn't reach: it runs in its own process group so expiry can tear down its whole tree (`run_with_timeout_impl`), which is what makes the bound bound anything, so a Ctrl-C during one waits out the remaining timeout. Stream mode counts any signal: output already streamed to the terminal, and user-initiated kills are normalized upstream to the originating SIGINT/SIGTERM (`seen_signal` in `shell_exec`, the concurrent runner's originating-signal override).\n- The check happens **before** any `FailureStrategy` branch — Warn must NOT swallow signal-derived errors.\n- `handle_command_error` in `src/commands/command_executor.rs` enforces this for hook and alias pipelines (foreground and concurrent groups); `for_each.rs` enforces it for the worktree loop. New code that loops over child processes calls `.interrupt_signal()` on per-iteration errors and breaks.\n\n### Project Commands Run Only After Approval\n\n**Policy:** project-defined commands (`pre-*` / `post-*` hooks, `[aliases]`, `--execute` bodies from project config) are arbitrary code shipped in a repo the user may have just cloned, so they run only after the approval system (`Approvals` plus `approve_command_batch` / `approve_or_skip` in `src/commands/command_approval.rs`) clears them. Never build a code path that runs project commands without that gate. A context that can't prompt (a TUI mid-render, a background recovery path) consults the approval state read-only and runs only the already-approved subset: `commands::picker::do_removal` builds the plan via `HookPlan::approve_readonly` (no prompt).\n\n**Why:** the gate is the only thing between `git clone && wt switch` and a `post-switch` hook running `curl … | sh`. A \"we already validated the operation, so run the hooks too\" shortcut turns every command that touches project config into remote code execution.\n\n**Implementation:** the operation-driven hooks (`pre-merge`, `post-merge`, `pre-remove`, `post-remove`, `post-switch`, `pre-start`, `post-start`) are gated *before* a state mutation and run *after* it, so a second config read could select an unapproved command. `src/commands/hook_plan.rs` closes this structurally: each gate (`wt remove` / `wt merge` / `wt step prune` / `wt switch`) selects the command set once into an immutable `ApprovedHookPlan` (`HookPlan::approve`); the executor consumes only that value via `execute_planned_hook` / `register_planned` and holds no `ProjectConfig` to re-derive from, so re-selection is a compile error, not a review check. An empty plan (`--no-hooks`, declined, or no project config) runs nothing. The adjacent hooks with no gate→exec mutation (`pre-commit`, `post-commit`, `pre-switch`, `wt hook <type>`, aliases) still resolve config at invocation via `execute_hook` / `HookAnnouncer::register`. See `src/commands/hook_plan.rs` and the `commands::hooks` module spec.\n\n## Hook Output Logs\n\n`.git/wt/logs/` layout — per-branch and repo-wide log paths, plus the `sanitize_for_filename` filename rule: the `HookLog` spec in `src/commands/process.rs`. The top-level file-vs-directory split that `wt config state` walks: the \"Log layout invariant\" in `src/commands/config/state.rs`.\n\n## Coverage\n\n**`codecov/patch` gates the merge, not the design.** Write the change the design calls for, then deal with the check. A predicted red patch is never a reason to shelve or water down an improvement, and neither the code nor the tests get contorted to move the number. The patch target is `auto` (the base commit's project coverage), so a handful of missed lines flips a small patch red, and a refactor that only relocates existing uncovered lines pulls their misses in without changing behavior.\n\n**NEVER merge a PR with failing `codecov/patch` without explicit user approval.** It is marked \"not required\" in GitHub but still gates merge. On PR heads codecov posts **check runs** (codecov GitHub App), not commit statuses: poll with `gh pr checks <number>` or the check-runs API; the combined-status API (`/commits/<sha>/status`) never shows them, and the check run lands a few minutes after the `code-coverage` job finishes. On failure, close the gap where it's real — write tests, or delete code that's genuinely unused (a specialized error handler where falling through to the general one suffices; never a rarely-reached backstop that's load-bearing). Where it isn't real — misses that predate the change, or a path with no deterministic trigger — push the change and ask before merging, handing over the arithmetic: which lines, why they can't be covered, what the patch percentage comes to. Coverage runs include `--features shell-integration-tests` (CI `code-coverage` and local `task coverage`) — don't dismiss failures by claiming the feature is off. Investigation commands, moved-line false positives, and the \"N functions mismatched\" warning: `tests/CLAUDE.md`.\n\n## Benchmarks & Traces\n\n`cargo bench --bench list <filter>` (Criterion takes a positional substring filter; there's no `--skip`). `cargo run -p wt-perf -- timeline -- <args>` traces one `wt` invocation. Real-repo benchmarks clone rust-lang/rust on first run. Benchmarks run as a standalone scheduled workflow (`.github/workflows/benchmarks.yaml`, daily cron plus `workflow_dispatch`), not on PRs, so they never gate a merge; only `test (linux|macos|windows)` block it. Filter map, expected numbers, and trace queries: `benches/CLAUDE.md`.\n\n## Code Quality\n\n### Use Existing Dependencies\n\nCheck `Cargo.toml` before hand-rolling a utility:\n\n| Need | Use | Not |\n|------|-----|-----|\n| Path normalization | `path_slash::PathExt::to_slash_lossy()` | `.to_string_lossy().replace('\\\\', \"/\")` |\n| Shell escaping | `shell_escape::unix::escape()` | manual quoting |\n| ANSI colors | `color_print::cformat!()` | raw escape codes |\n| Template var detection | `minijinja::undeclared_variables(false)` | regex/substring on `{{ var }}` |\n\n### Other\n\n- **Don't suppress warnings** with `#[allow(dead_code)]` — delete the code or add `// TODO(topic): used by <upcoming work>`.\n- **System docstrings** — complex systems (state machines, cached state, cross-module coordination, non-obvious invalidation) get a module-level spec docstring (purpose, key decisions, contracts, invariants); keep it current. Exemplar: `commands/list/collect/mod.rs`.\n- **No test code in library code** — no `#[cfg(test)]` convenience methods on library types; tests call the real API or define their own helpers.\n- **Multiline strings** — plain literals with real embedded newlines (`r#\"…\"#` to avoid escaping `\"`); never `\\` continuation (silently strips following whitespace) or `concat!()`. Place long constants at module level.\n\n## Error Handling\n\n`anyhow` with context. `bail!` for business-logic errors (dirty worktree, missing branch, invalid state); `.context()` for wrapping I/O and external-command failures. Never `.expect()` / `.unwrap()` in a function returning `Result` — use `?`, `bail!`, or return an error.\n\n## Config Deprecation\n\nAll config deprecation lives in one layer: pre-deserialization TOML migration in `src/config/deprecation.rs`. `migrate_content()` rewrites deprecated patterns into canonical form before serde parses; `check_and_migrate()` reuses it, and additionally detects patterns and emits per-process-deduped warnings (the user materializes migrations via `wt config update`). **Never silently drop an old config key** — that's a silent behavior change for users; migrate it.\n\nEvery deprecation is one row in the `DEPRECATION_RULES` table: a single idempotent function that rewrites the pattern AND returns the `DeprecationKind`s for what it changed — there is no separate detection function, so detection and migration share one predicate and cannot drift. Detection runs the same functions against a scratch copy of the document (progressively, so a rule sees earlier rules' rewrites); the invariant for warning rules is **a warning fires exactly when `wt config update` would change the file**, pinned by `test_warning_fires_iff_update_changes` — add new edge cases to its battery. The row variant decides when the rewrite applies: `Structural` rewrites on every load; `UpdateOnly` only via `wt config update`, for deprecated forms that still work at runtime; `Silent` rewrites on every load with no warning — its function signature has no channel for a kind, which is what scopes the invariant to `Structural` and `UpdateOnly`; `PendingDefault` adopts a default a future release switches — `wt config update` writes the upcoming value (currently `[list] json-schema = 2`), inert while the system config layer defines the key — update-pass only, scoped to the config kind that owns the key, and excluded from load warnings by `is_pending_default`: it satisfies the same iff at the surface that reads the setting, where the `wt list` JSON nag fires exactly when update would write. Table order is both the warning-emission order and the migration order. Each `DeprecationKind` carries its own display payload, so `format_deprecation_warnings()` is one match over the kinds. A config that can't be rewritten safely (a malformed value, an occupied destination key) is left untouched and unwarned — serde's type or unknown-field error is the messaging; an empty deprecated section is also left alone, with no message at all (it contributes no config). Adding a deprecation: (1) one idempotent migrate-and-report function; (2) a `DeprecationKind` variant plus its match arm in `format_deprecation_warnings()`; (3) a `DEPRECATION_RULES` row; (4) for a removed top-level section, add a `DeprecatedSection` to `DEPRECATED_SECTION_KEYS` (canonical key plus display form) so `warn_unknown_fields` defers to the deprecation messaging and suggests the correct config file. A silently-migrated rename (e.g. `pre-create` → `pre-start`) is a `Silent` row with no variant. Renaming a field within a section follows the same shape via a TOML-level rename function (see `migrate_negated_bool`); the struct never needs the old field since migration precedes serde.\n\n## Adding CLI Commands\n\nRecipe, help-text placement, and flag-description conventions: `src/commands/CLAUDE.md`.\n\n## Accessor Function Naming\n\n| Prefix | Returns | Side effects | Absent → | Example |\n|--------|---------|--------------|----------|---------|\n| (bare noun) | `Option<T>` / `T` | none (may cache) | None/default | `config()`, `switch_previous()` |\n| `set_*` | `Result<()>` | writes state | errors | `set_config()` |\n| `require_*` | `Result<T>` | none | errors | `require_branch()` |\n| `fetch_*` | `Result<T>` | network I/O | errors | `fetch_pr_info()` |\n| `load_*` | `Result<T>` | file I/O | errors | `load_project_config()` |\n\nNo `get_*` — bare nouns follow Rust stdlib convention.\n\n## Repository Caching\n\n`Repository` caches read-only values via `Arc<RepoCache>` (cloning shares it). What is and isn't cached, the `list_worktrees()` post-mutation invariant, the two storage patterns, and the in-memory-`RepoCache`-vs-persistent-`sha_cache` decision (cheap-and-hot → in-memory get-or-create; expensive → disk; both → in-memory front over disk back): the `# Caching` section in `src/git/repository/mod.rs`.\n\n## Releases\n\nUse the `release` skill (version bump, changelog, crates.io publish, GitHub release).\n"},"items":[{"name":"CLAUDE.md","path":"CLAUDE.md","title":"CLAUDE.md","content":"# Worktrunk Development Guidelines\n\n## Quick Start\n\n```bash\ncargo run -- hook pre-merge --yes   # all tests + lints (runs automatically in wt merge)\n```\n\nClaude Code web: run `task setup-web` first. Test commands, isolation, and coverage investigation: `tests/CLAUDE.md`.\nCodex Cloud: use the checksum-gated setup in `scripts/codex-cloud/README.md`.\n\n## Project Status\n\nMaturing mode: a growing user base, so balance clean design with compatibility.\n\n- External-interface breaks need justification (a real improvement, not cleanup); prefer deprecation warnings over silent breaks.\n- **Protected interfaces:** config file format (`wt.toml`, user config) and CLI flags/arguments. Everything else (internal APIs, output formatting, log locations) is flexible.\n- No Rust library compatibility concerns (CLI tool only).\n- MSRV: latest stable − 1, bumped during weekly tend maintenance (`running-tend` skill).\n\n## Terminology\n\nUse consistently in docs, help text, and code comments:\n\n- **main worktree** — the original git directory (from clone/init); bare repos have none\n- **linked worktree** — created via `git worktree add` (git's term)\n- **primary worktree** — the \"home\" worktree: main worktree for normal repos, default-branch worktree for bare repos\n- **default branch** — the branch (main, master, …), not \"main branch\"\n- **target** — destination for merge/rebase/push (\"merge target\"). Never use \"target\" for worktrees; say \"worktree\"\n\n## Skills\n\nLoad relevant skills before starting; reload when scope changes mid-session. Project-local skills in `.claude/skills/`:\n\n- `writing-user-outputs` — before editing code that calls `warning_message`, `hint_message`, `error_message`, `info_message`, `eprintln`, `println`, or otherwise produces user-visible strings (CLI help, progress UI, snapshots).\n- `running-tend` — operating in CI or writing tend workflows.\n- `release` — cutting a release.\n\n## Worktree Model\n\n- Worktrees are **addressed by branch name**, with a worktree's own path as an alias — resolved branch-first by `Repository::resolve_worktree`, the one canonicalizer every worktree-naming argument routes through. A path is not a second addressing scheme: it names what a branch cannot (a detached worktree, one of two checkouts of a branch). So document arguments as taking a branch, state the path alias once rather than per argument, and give a new argument the canonicalizer rather than its own rule.\n- Each worktree maps to **exactly one branch**.\n- **Never retarget an existing worktree** to a different branch; create/switch/remove instead. (Sole exception: `wt step promote`, experimental, exchanges branches between two worktrees.)\n\n## Documentation\n\nBehavior changes require doc updates. `src/cli/mod.rs` (`after_long_help` plus clap attributes) is the PRIMARY SOURCE for command pages; their rendered mirrors in `docs/content/` and `skills/worktrunk/reference/` are generated, as is all of `plugins/worktrunk/skills/` — but both directories also hold hand-edited primaries (non-command docs in `docs/content/`, skill-only pages like `shell-integration.md` in the reference dir), so check which file is primary in the sync taxonomy before editing. Ask: \"does `--help` still describe what the code does?\" `cargo test --test integration test_docs_are_in_sync` checks doc sync; editing help text (`after_long_help`, `about`, arg docs) also changes the rendered `--help` snapshots, which that test leaves untouched — `cargo insta test --accept --test integration -- test_help` regenerates them (the pre-merge hook runs both). Sync taxonomy, help-text authoring (three render contexts, link text, config-TOML blocks): `docs/CLAUDE.md`.\n\n## Plugin Layout\n\nPer-tool layout and path resolution (Claude/Codex/Gemini), the convention-only Claude manifest, the Codex inline-hooks rationale, the generated plugin-skills mirror, the accepted `wt-switch-create` tradeoff, and `test_plugin_layout_is_consolidated`: `plugins/worktrunk/CLAUDE.md`.\n\n## Data Safety\n\nNever risk data loss without explicit user consent. A failed command that preserves data beats a \"successful\" one that silently destroys work.\n\n- **Prefer failure over silent loss** — if an operation might destroy untracked files, uncommitted changes, or user data, fail with an error.\n- **Explicit consent for destructive ops** — force-removing data (e.g. `--force` on remove) requires the user to explicitly request it.\n- **No implicit destructive side effects** — never silently delete/overwrite as a side effect of an unrelated operation; make cleanup a separate explicit action the user chooses.\n- **Favor the failing variant on races** — `git reset --keep` (fails if tracked files were modified) over `--hard`; `git checkout --merge` over `--force`. If no safer variant exists, document the risk inline.\n- **Time-of-check vs time-of-use** — be conservative when there's a gap between the safety check and the operation. `wt merge` verifies clean before rebasing, but files could appear before cleanup — don't force-remove during cleanup.\n- **Replace files, never truncate them** — `fs::write` truncates before it writes, so a crash mid-write leaves the file empty. Every write to a file worktrunk can't put back (rc files, shell wrappers, `config.toml`, `approvals.toml`, another tool's `settings.json`) goes through `utils::write_atomically`, which renames a sibling temp file over the target; the spec on that function covers symlinks, mode, and what a rename costs. Regenerable content (the cache, the `-vv` diagnostic report) keeps the plain write.\n\nThese stop where git's own protections stop, and matching git is deliberate in each case. The named spec says why:\n\n- `wt merge` and `wt step push` overwrite an ignored file in the destination worktree whose path the incoming commits track, exactly as a `git merge` run there would (`src/commands/worktree/push.rs`).\n- Removal's final dirty-worktree gate is answered by the fsmonitor daemon under `core.fsmonitor`, exactly as `git worktree remove`'s own gate is (`src/git/remove.rs`).\n\nFull inventory: FAQ [What files does Worktrunk create?](docs/content/faq.md#what-files-does-worktrunk-create) and [What can Worktrunk delete?](docs/content/faq.md#what-can-worktrunk-delete). Review new code that changes this surface against those sections.\n\n## Command Execution Principles\n\n### All Commands Through `shell_exec::Cmd`\n\nEvery external command goes through `shell_exec::Cmd` for consistent debug logging (`$ git status [worktree-name]`) and `[wt-trace]` timing. Never call `cmd.output()` directly. For git, prefer `Repository::run_command()` (wraps `Cmd` with worktree context). `Cmd` has four execution modes — `run` (capture), `stream` (inherit stdio), `delayed_stream` (buffer then stream to stderr, for slow ops like `git worktree add`), and `pipe_into` (two-stage pipe). Pipe stdin via `.stdin_bytes(...)`.\n\n```rust\nCmd::new(\"git\").args([\"status\", \"--porcelain\"]).current_dir(&wt).context(\"worktree-name\").run()?;\nCmd::new(\"gh\").args([\"pr\", \"list\"]).run()?;  // no context for standalone tools\n```\n\n**The `[wt-trace]` command record has one emitter: `CommandTrace` in `src/trace/emit.rs`.** The grammar lives there too (don't hand-write `log::debug!(\"[wt-trace] …\")`). `CommandTrace::{complete,fail}` are the only callers of the private `command_completed`/`command_errored` writers, so a subprocess is either traced through the guard or produces no command record. Most spawns get this for free via `Cmd`. A few spawn sites have I/O shapes `Cmd` can't model and construct a `CommandTrace` directly: the concurrent-command runner (`output/concurrent.rs`), pipeline steps (`commands/run_pipeline.rs`), `wt step tether`, and the fsmonitor daemon launch. **Any new spawn site that runs an in-process command must construct a `CommandTrace` (start it just before spawn; `complete(success)` after wait, `fail(err)` on spawn/wait error)** — otherwise the command shows up as an unattributed gap in `wt-perf timeline`. The guard is `#[must_use]` and trips a debug-build assertion if dropped unresolved, so a forgotten `complete`/`fail` fails tests rather than silently going untraced. Detached background children (`commands/process.rs`) and interactive helpers (pagers, shell probes) are intentionally untraced — they outlive the invocation or aren't part of its timeline.\n\n### Git-Discovery Env Vars Follow Who Chose the Cwd\n\nGit resolves `GIT_DIR`/`GIT_WORK_TREE` (and the rest of `INHERITED_GIT_PATH_VARS`) before walking up from the cwd, so an inherited value silently overrides a child's working directory. **Any spawn site that relocates a user command into a `wt`-chosen worktree — hooks, `wt step for-each`, the `--execute` no-integration fallback — must scrub these vars** (`Cmd::scrub_git_discovery_env` or `scrub_git_discovery_env_vars`); children running in the user's own context (aliases, `commit.generation`) and `wt`'s internal git plumbing keep the inherited context (absolutized). Full site classification and rationale: `scrub_git_discovery_env_vars` in `src/shell_exec.rs`.\n\n### Real-time Output Streaming\n\nStream command output line-by-line rather than buffering. Responsiveness is a priority.\n\n### Structured Output Over Error-Message Parsing\n\nPrefer exit codes / `--porcelain` / `--json` over parsing human-readable messages, which break on locale, version, and rewording changes. `git merge-base` exit codes encode meaning (0 found, 1 no common ancestor, 128 invalid ref) — branch on `status.code()`, not message text.\n\n| Tool | Fragile | Structured |\n|------|---------|------------|\n| `git diff` | `--stat` (localized) | `--numstat`, `--shortstat` (`(+)`/`(-)` hardcoded) |\n| `git status` | default | `--porcelain=v2` |\n| `git merge-base` | error messages | exit codes |\n| `gh` / `glab` | default | `--json` |\n\nWhen no structured alternative exists, document the fragility inline.\n\n### Immutable Ids Over List Positions\n\n`stash@{0}` names a position in a list any process can reorder, so a handle captured before a mutation window and used after it can resolve to a different object — restoring the target worktree's autostash by position after `git push` silently restored a concurrent writer's entry and reported success. Capture the immutable id instead (`git stash list --format=%H`, `git stash create`, `rev-parse`) and act on that; where an operation accepts only a positional selector, re-derive it from something stable immediately beforehand. An index into a collection `wt` owns is a different thing — this is about namespaces other processes can mutate. The strongest form is not to enter the shared namespace at all: the autostash this rule came from was later deleted outright, replaced by a two-tree merge that leaves the target worktree's changes in place (`advance_target` in `src/commands/worktree/push.rs`).\n\n### Network Access\n\nworktrunk is local-first: the network is touched only when the user asked for it, and only where reaching the wire directly serves that request. **One detection helper is exempt:** the *first* `Repository::default_branch()` per repo may fall through to `git ls-remote`; the result caches in `worktrunk.default-branch` and every later call is local. The query is bounded by `REMOTE_DETECTION_TIMEOUT` — nothing in git bounds it, and an unreachable host costs ~127 s per address on Linux — and a query that hits the bound falls back to local inference *without* caching it, so an outage can't make a guess permanent. No other detection helper may add a similar fallback.\n\nWhy: silent \"lookup\" paths that walk to the wire (alias dispatch, hook context build, recovery) stall commands the user wouldn't expect to do network work, worst on a fresh clone. The `default_branch()` bootstrap keeps a fresh clone usable while bounding the exception to one helper firing at most once per repo.\n\n**Network never blocks the first write.** Fast output to the terminal is the priority (Real-time Output Streaming, above): every command paints from local data first, then network-derived detail streams in progressively behind it. A command that can't render its first frame until `gh` or `git fetch` returns is the failure mode, worst on a fresh clone or a slow link. Before adding an accessor that could reach the wire (`gh`, `glab`, `git fetch`, `git ls-remote`, HTTP), confirm it renders progressively and never gates the first paint. A synchronous hot path like a shell prompt is stricter: it must not reach the wire at all, even progressively. `wt list statusline` is not such a path despite running on every prompt, because Claude Code consumes its output asynchronously.\n\n**The picker is the most forgiving home for network work, because its lifetime is bounded by the user, not the job.** It paints immediately, the user browses, and a slow forge call streams into the rows whenever it arrives; if the user picks first, the picker's exit cancels the unfinished request (`shell_exec::cancel_background_commands`), so its latency never costs anything. A run-to-completion command is less forgiving: `wt list` renders progressively but still cannot *finish* until every task returns, so a slow `gh` call extends the command the user is waiting on. Prefer the picker for live forge data, and fetch it there progressively.\n\nWhat currently reaches the wire:\n\n- `wt list --full`, `wt list statusline` — CI status; also plain `wt list` (table) when `[list] columns` names `ci`, which forces the column (and its fetch) on without `--full`. `--format json` plans off `--full` alone, so a display setting can't send a machine-readable call to a forge\n- `wt switch` (interactive picker, no target) — per-row CI status, primed from the local cache then fetched live and streamed into the rows; once a row's CI fetch surfaces an open PR/MR, a per-row background `gh pr view <n> --json comments` (`glab api …/notes` on GitLab) fills that row's `comments` preview tab — the same fetch a `--prs` row makes, spawned once per row from `progressive_handler` (see `picker::prs::spawn_comments_fetch`). The `comments` tab is the only PR data fetched lazily here; `pr` rides the CI call and `log` is the local `git log`\n- generating a branch summary with a `commit.generation` command\n- generating a commit message with a `commit.generation` command\n- `wt switch pr:<n>`, `wt switch mr:<n>` — host API to resolve the PR/MR, then `git fetch` of its branch\n- `wt switch --prs` — one `gh pr list` / `glab mr list` to populate the interactive picker (streamed in after the frame paints), then a per-row background `gh pr view <n> --json comments` (`glab api …/notes` on GitLab) to fill each row's `comments` preview tab, plus a `gh pr view <n> --json commits` / `glab api …/commits` for the `log` tab **only when the head commit isn't already local** — a `--prs` row whose `headRefOid`/`sha` resolves in the object store renders the `log` tab from a local `git log` with no network (off the pool, once per row when the rows land — see `picker::prs::spawn_pr_previews`)\n- `wt config show --full` — version check against GitHub\n- the first `Repository::default_branch()` per repo — `git ls-remote` (above)\n\n### Signal Handling: Ctrl-C Cancels the Current Command\n\nWhen a child process exits from a signal (SIGINT, SIGTERM), every loop in the foreground execution path MUST abort rather than continue to the next iteration. This applies to worktree loops (`wt step for-each`), hook pipelines, alias steps, concurrent groups, and any future code running multiple child processes in sequence.\n\nWhy: wt installs a `signal_hook` SIGINT/SIGTERM handler so it can forward signals to child process groups before exiting cleanly. As a side effect wt itself does not die from the user's Ctrl-C — only the current child does. Without this policy a single Ctrl-C against `wt merge` would charge through the remaining hook steps, with `FailureStrategy::Warn` silently swallowing each interrupt.\n\n- Signal-derived child exits surface structurally: stream mode (`Cmd::stream`) as `WorktrunkError::ChildProcessExited { signal: Some(sig), .. }`, capture mode (`Cmd::run`) as `CommandError { signal: Some(sig), .. }`. These fields are the structured channel — never sniff `code >= 128` or parse error messages.\n- Detect via `err.interrupt_signal()` (the `worktrunk::git::ErrorExt` trait). When it returns `Some(signal)`, propagate as `WorktrunkError::Interrupted { signal, hint }` and break the loop. `Interrupted` exits `128 + signal` (130 SIGINT, 143 SIGTERM) and renders once, at exit, per shell convention: silent for SIGINT (the terminal echoed `^C`), `Terminated` for SIGTERM — the line the shell would print if wt weren't trapping the signal. `hint` carries an optional recovery line for state the interrupt left behind (e.g. a mid-rebase worktree).\n- In capture mode only SIGINT/SIGTERM classify as interrupts. Capture children get no forwarding or escalation, and their captured output would be discarded by the silent exit — so a child killed by any other signal (a crash, an OOM kill) surfaces as a visible error instead. A capture child with a `Cmd::timeout` is the one the tty broadcast doesn't reach: it runs in its own process group so expiry can tear down its whole tree (`run_with_timeout_impl`), which is what makes the bound bound anything, so a Ctrl-C during one waits out the remaining timeout. Stream mode counts any signal: output already streamed to the terminal, and user-initiated kills are normalized upstream to the originating SIGINT/SIGTERM (`seen_signal` in `shell_exec`, the concurrent runner's originating-signal override).\n- The check happens **before** any `FailureStrategy` branch — Warn must NOT swallow signal-derived errors.\n- `handle_command_error` in `src/commands/command_executor.rs` enforces this for hook and alias pipelines (foreground and concurrent groups); `for_each.rs` enforces it for the worktree loop. New code that loops over child processes calls `.interrupt_signal()` on per-iteration errors and breaks.\n\n### Project Commands Run Only After Approval\n\n**Policy:** project-defined commands (`pre-*` / `post-*` hooks, `[aliases]`, `--execute` bodies from project config) are arbitrary code shipped in a repo the user may have just cloned, so they run only after the approval system (`Approvals` plus `approve_command_batch` / `approve_or_skip` in `src/commands/command_approval.rs`) clears them. Never build a code path that runs project commands without that gate. A context that can't prompt (a TUI mid-render, a background recovery path) consults the approval state read-only and runs only the already-approved subset: `commands::picker::do_removal` builds the plan via `HookPlan::approve_readonly` (no prompt).\n\n**Why:** the gate is the only thing between `git clone && wt switch` and a `post-switch` hook running `curl … | sh`. A \"we already validated the operation, so run the hooks too\" shortcut turns every command that touches project config into remote code execution.\n\n**Implementation:** the operation-driven hooks (`pre-merge`, `post-merge`, `pre-remove`, `post-remove`, `post-switch`, `pre-start`, `post-start`) are gated *before* a state mutation and run *after* it, so a second config read could select an unapproved command. `src/commands/hook_plan.rs` closes this structurally: each gate (`wt remove` / `wt merge` / `wt step prune` / `wt switch`) selects the command set once into an immutable `ApprovedHookPlan` (`HookPlan::approve`); the executor consumes only that value via `execute_planned_hook` / `register_planned` and holds no `ProjectConfig` to re-derive from, so re-selection is a compile error, not a review check. An empty plan (`--no-hooks`, declined, or no project config) runs nothing. The adjacent hooks with no gate→exec mutation (`pre-commit`, `post-commit`, `pre-switch`, `wt hook <type>`, aliases) still resolve config at invocation via `execute_hook` / `HookAnnouncer::register`. See `src/commands/hook_plan.rs` and the `commands::hooks` module spec.\n\n## Hook Output Logs\n\n`.git/wt/logs/` layout — per-branch and repo-wide log paths, plus the `sanitize_for_filename` filename rule: the `HookLog` spec in `src/commands/process.rs`. The top-level file-vs-directory split that `wt config state` walks: the \"Log layout invariant\" in `src/commands/config/state.rs`.\n\n## Coverage\n\n**`codecov/patch` gates the merge, not the design.** Write the change the design calls for, then deal with the check. A predicted red patch is never a reason to shelve or water down an improvement, and neither the code nor the tests get contorted to move the number. The patch target is `auto` (the base commit's project coverage), so a handful of missed lines flips a small patch red, and a refactor that only relocates existing uncovered lines pulls their misses in without changing behavior.\n\n**NEVER merge a PR with failing `codecov/patch` without explicit user approval.** It is marked \"not required\" in GitHub but still gates merge. On PR heads codecov posts **check runs** (codecov GitHub App), not commit statuses: poll with `gh pr checks <number>` or the check-runs API; the combined-status API (`/commits/<sha>/status`) never shows them, and the check run lands a few minutes after the `code-coverage` job finishes. On failure, close the gap where it's real — write tests, or delete code that's genuinely unused (a specialized error handler where falling through to the general one suffices; never a rarely-reached backstop that's load-bearing). Where it isn't real — misses that predate the change, or a path with no deterministic trigger — push the change and ask before merging, handing over the arithmetic: which lines, why they can't be covered, what the patch percentage comes to. Coverage runs include `--features shell-integration-tests` (CI `code-coverage` and local `task coverage`) — don't dismiss failures by claiming the feature is off. Investigation commands, moved-line false positives, and the \"N functions mismatched\" warning: `tests/CLAUDE.md`.\n\n## Benchmarks & Traces\n\n`cargo bench --bench list <filter>` (Criterion takes a positional substring filter; there's no `--skip`). `cargo run -p wt-perf -- timeline -- <args>` traces one `wt` invocation. Real-repo benchmarks clone rust-lang/rust on first run. Benchmarks run as a standalone scheduled workflow (`.github/workflows/benchmarks.yaml`, daily cron plus `workflow_dispatch`), not on PRs, so they never gate a merge; only `test (linux|macos|windows)` block it. Filter map, expected numbers, and trace queries: `benches/CLAUDE.md`.\n\n## Code Quality\n\n### Use Existing Dependencies\n\nCheck `Cargo.toml` before hand-rolling a utility:\n\n| Need | Use | Not |\n|------|-----|-----|\n| Path normalization | `path_slash::PathExt::to_slash_lossy()` | `.to_string_lossy().replace('\\\\', \"/\")` |\n| Shell escaping | `shell_escape::unix::escape()` | manual quoting |\n| ANSI colors | `color_print::cformat!()` | raw escape codes |\n| Template var detection | `minijinja::undeclared_variables(false)` | regex/substring on `{{ var }}` |\n\n### Other\n\n- **Don't suppress warnings** with `#[allow(dead_code)]` — delete the code or add `// TODO(topic): used by <upcoming work>`.\n- **System docstrings** — complex systems (state machines, cached state, cross-module coordination, non-obvious invalidation) get a module-level spec docstring (purpose, key decisions, contracts, invariants); keep it current. Exemplar: `commands/list/collect/mod.rs`.\n- **No test code in library code** — no `#[cfg(test)]` convenience methods on library types; tests call the real API or define their own helpers.\n- **Multiline strings** — plain literals with real embedded newlines (`r#\"…\"#` to avoid escaping `\"`); never `\\` continuation (silently strips following whitespace) or `concat!()`. Place long constants at module level.\n\n## Error Handling\n\n`anyhow` with context. `bail!` for business-logic errors (dirty worktree, missing branch, invalid state); `.context()` for wrapping I/O and external-command failures. Never `.expect()` / `.unwrap()` in a function returning `Result` — use `?`, `bail!`, or return an error.\n\n## Config Deprecation\n\nAll config deprecation lives in one layer: pre-deserialization TOML migration in `src/config/deprecation.rs`. `migrate_content()` rewrites deprecated patterns into canonical form before serde parses; `check_and_migrate()` reuses it, and additionally detects patterns and emits per-process-deduped warnings (the user materializes migrations via `wt config update`). **Never silently drop an old config key** — that's a silent behavior change for users; migrate it.\n\nEvery deprecation is one row in the `DEPRECATION_RULES` table: a single idempotent function that rewrites the pattern AND returns the `DeprecationKind`s for what it changed — there is no separate detection function, so detection and migration share one predicate and cannot drift. Detection runs the same functions against a scratch copy of the document (progressively, so a rule sees earlier rules' rewrites); the invariant for warning rules is **a warning fires exactly when `wt config update` would change the file**, pinned by `test_warning_fires_iff_update_changes` — add new edge cases to its battery. The row variant decides when the rewrite applies: `Structural` rewrites on every load; `UpdateOnly` only via `wt config update`, for deprecated forms that still work at runtime; `Silent` rewrites on every load with no warning — its function signature has no channel for a kind, which is what scopes the invariant to `Structural` and `UpdateOnly`; `PendingDefault` adopts a default a future release switches — `wt config update` writes the upcoming value (currently `[list] json-schema = 2`), inert while the system config layer defines the key — update-pass only, scoped to the config kind that owns the key, and excluded from load warnings by `is_pending_default`: it satisfies the same iff at the surface that reads the setting, where the `wt list` JSON nag fires exactly when update would write. Table order is both the warning-emission order and the migration order. Each `DeprecationKind` carries its own display payload, so `format_deprecation_warnings()` is one match over the kinds. A config that can't be rewritten safely (a malformed value, an occupied destination key) is left untouched and unwarned — serde's type or unknown-field error is the messaging; an empty deprecated section is also left alone, with no message at all (it contributes no config). Adding a deprecation: (1) one idempotent migrate-and-report function; (2) a `DeprecationKind` variant plus its match arm in `format_deprecation_warnings()`; (3) a `DEPRECATION_RULES` row; (4) for a removed top-level section, add a `DeprecatedSection` to `DEPRECATED_SECTION_KEYS` (canonical key plus display form) so `warn_unknown_fields` defers to the deprecation messaging and suggests the correct config file. A silently-migrated rename (e.g. `pre-create` → `pre-start`) is a `Silent` row with no variant. Renaming a field within a section follows the same shape via a TOML-level rename function (see `migrate_negated_bool`); the struct never needs the old field since migration precedes serde.\n\n## Adding CLI Commands\n\nRecipe, help-text placement, and flag-description conventions: `src/commands/CLAUDE.md`.\n\n## Accessor Function Naming\n\n| Prefix | Returns | Side effects | Absent → | Example |\n|--------|---------|--------------|----------|---------|\n| (bare noun) | `Option<T>` / `T` | none (may cache) | None/default | `config()`, `switch_previous()` |\n| `set_*` | `Result<()>` | writes state | errors | `set_config()` |\n| `require_*` | `Result<T>` | none | errors | `require_branch()` |\n| `fetch_*` | `Result<T>` | network I/O | errors | `fetch_pr_info()` |\n| `load_*` | `Result<T>` | file I/O | errors | `load_project_config()` |\n\nNo `get_*` — bare nouns follow Rust stdlib convention.\n\n## Repository Caching\n\n`Repository` caches read-only values via `Arc<RepoCache>` (cloning shares it). What is and isn't cached, the `list_worktrees()` post-mutation invariant, the two storage patterns, and the in-memory-`RepoCache`-vs-persistent-`sha_cache` decision (cheap-and-hot → in-memory get-or-create; expensive → disk; both → in-memory front over disk back): the `# Caching` section in `src/git/repository/mod.rs`.\n\n## Releases\n\nUse the `release` skill (version bump, changelog, crates.io publish, GitHub release).\n","category":"root","tokens":7065}]}