Commit Graph

1003 Commits

Author SHA1 Message Date
Maximilian Roos 99a381ba41 Release v0.45.2 2026-04-28 09:31:51 -07:00
Maximilian Roos 4ee813c15f Release v0.45.1 2026-04-27 18:14:39 -07:00
Maximilian Roos d567656207 Release v0.45.0 2026-04-27 08:04:52 -07:00
Worktrunk Bot 383774b38b docs: document conda-forge / pixi installation (#2425)
## Problem

The README install section lists Homebrew, Cargo, winget, and pacman,
but does not mention the conda-forge package — which has been published
since 0.32.0 and is currently at 0.44.0 with builds for `linux-64`,
`linux-aarch64`, `osx-64`, `osx-arm64`, and `win-64`
([anaconda.org/conda-forge/worktrunk](https://anaconda.org/conda-forge/worktrunk)).
Reported in #2424.

## Solution

Adds a "Conda / Pixi" entry to the install section in
`docs/content/worktrunk.md`, between Cargo and the Windows expandable.
README and the worktrunk skill reference are auto-synced from this
source.

## Testing

`cargo test --test integration test_docs_are_in_sync` passes.

---
Closes #2424 — automated triage

---------

Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-04-26 11:31:04 -07:00
Maximilian Roos 4cbc8ca5fd refactor(docs): drop mirrored help-page close; align convert_console_blocks_in_docs error channel (#2422)
## Summary

Three follow-ups from #2419 review.

- **Bare close everywhere.** With inner snapshot wrappers gone from
command pages (#2419), no `AUTO-GENERATED` markers nest inside the
help-page region anywhere in `docs/content/*.md`. The mirrored close
(`<!-- END AUTO-GENERATED from \`wt cmd --help-page\` -->`) was the only
remaining variant of the close form; collapsed to bare `MARKER_CLOSE`.

  - `src/help.rs::PageMode::emit_footer()` no longer takes `subcommand`.
- The help-page regex in `readme_sync.rs` matches via non-greedy `.*?`
to bare `MARKER_CLOSE`, with a comment pointing at the new invariant
test.
- `AUTO_GENERATED_MARKER_PATTERN` strip regex built from the constants.

Added **`test_no_nested_auto_generated_markers`** — walks
`docs/content/*.md` and fails if any `AUTO-GENERATED` open ever appears
inside an already-open region. This is the explicit invariant that
bare-close pairing depends on; if a future change tries to re-introduce
nesting (e.g., restore an inner snapshot wrapper around terminal
shortcodes), the test catches it before the subtle "regex chops region
at first inner close" failure mode lands.

- **Aligned error channel.** `convert_console_blocks_in_docs` now
returns `(Vec<String>, Vec<String>)` like its sibling sync steps, with
per-file error capture for `read_dir`, dir entries, and
`read_to_string`. The caller passes errors through the same `tag()`
aggregation as everything else, so a transient I/O failure on one file
no longer aborts the whole pipeline silently. Also fixes the matching
clippy warning (`is_some_and(|e| e == \"md\")` → `is_none_or(|e| e !=
\"md\")`).

- **Docs alignment.** `docs/CLAUDE.md` updated in two places where the
prose still documented the mirrored close as the canonical form.

Visual check via curl across 12 dev-server pages confirmed no leakage of
any prior marker form. Adversarial review (subagent /popper-style)
caught the stale prose; otherwise no regressions found.

Net diff: +113/-49 (the growth is the new invariant test and explicit
per-file error handling; structural simplification shows as -49).

## Test plan

- [x] `cargo test --test integration readme_sync` — 12 sync tests pass
(was 11; +1 for the new invariant test)
- [x] `cargo test --test integration` — full integration suite (1558
tests) pass
- [x] `cargo test --test integration
test_no_nested_auto_generated_markers` — new guard test passes; manually
verified it fires on synthetic nesting
- [x] Single-pass convergence verified: `git checkout docs/ && cargo
test --test integration test_docs_are_in_sync && git diff --stat`
produces an empty diff after the first run
- [x] Visual check via local Zola dev server: 12 docs pages
(\`/worktrunk/\`, \`/llm-commits/\`, \`/claude-code/\`,
\`/tips-patterns/\`, \`/merge/\`, \`/step/\`, \`/remove/\`, \`/hook/\`,
\`/list/\`, \`/switch/\`, \`/config/\`, \`/faq/\`) — only HTML comments
contain marker text, no rendered leakage
- [x] `cargo clippy --all-targets --all-features` — clean
- [x] `cargo fmt --check` — clean

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-26 02:18:39 -07:00
Maximilian Roos 66a5becc25 refactor(tests): consolidate three sync tests; drop dead inner snapshot wrappers (#2419)
## Summary

Two follow-ups from #2418 review.

- **Test brittleness fix.** Three previously-separate sync tests had
data dependencies that nextest could interleave:
`test_readme_examples_are_in_sync` reads `docs/content/*.md`, which
`test_docs_quickstart_examples_are_in_sync` generates from snapshots.
Under parallelism, README sync could see stale docs and produce content
that required a second run to converge. Collapsed both into the existing
`test_command_pages_and_skill_files_are_in_sync` pipeline, renamed to
`test_docs_are_in_sync`. Steps run sequentially in dependency order;
single pass converges from a clean working tree.

Each step's errors and updated-file list are tagged with the pipeline
stage (`[command pages]`, `[standalone docs]`, `[README]`, etc.) so a
failure tells a developer which stage broke without reading the test
source. README failure also now reports `(N of M section(s) updated)`.

- **Dead inner snapshot wrappers.** `expand_command_placeholders`
wrapped each terminal shortcode in command pages with `<!-- ⚠️
AUTO-GENERATED from <snap> --> ... <!-- END AUTO-GENERATED -->` markers.
Command pages regenerate wholesale from `--help-page` each sync, so the
inner wrapper served no in-place-refresh purpose — it was dead weight
nested inside the outer help-page region's markers. Stripped from
`expand_command_placeholders`'s HTML branch; net -32 lines across six
command-page docs files.

The outer help-page region's mirrored close stays — it's load-bearing
whenever any nested `AUTO-GENERATED` marker appears in the body. Comment
in `src/help.rs:emit_footer` updated to explain the role. (My follow-up
note that the mirrored close was redundant turned out to be a
misanalysis — non-greedy `.*?` only safely pairs the open with the right
close when there are no nested closes; once the inner snapshot wrappers
were gone, the mirror became technically unnecessary, but keeping it
survives any future reintroduction of nesting at no cost.)

Renamed the test in `CLAUDE.md` (4 sites) and `docs/CLAUDE.md` (2 sites)
so the documented `cargo test` invocations resolve.

Net diff: −54 lines.

## Test plan

- [x] `cargo test --test integration readme_sync` — 11 sync tests pass
(was 13; minus the two collapsed)
- [x] `cargo test --test integration` — full integration suite (1557
tests) pass
- [x] **Single-pass convergence verified**: `git checkout docs/
README.md && cargo test --test integration test_docs_are_in_sync && git
diff --stat` produces an empty diff after the first run
- [x] Visual check via local Zola dev server: \`/worktrunk/\`,
\`/llm-commits/\`, \`/claude-code/\`, \`/tips-patterns/\`, \`/merge/\`,
\`/step/\`, \`/remove/\`, \`/hook/\`, \`/list/\` all render terminal
blocks cleanly with no leaked marker strings
- [x] Adversarial review of the consolidation (subagent /popper-style):
all 4 actionable findings (stale doc references, lost README count,
missing stage tags, etc.) addressed
- [x] `cargo clippy --all-targets --all-features` — clean
- [x] `cargo fmt --check` — clean

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-25 19:20:44 -07:00
Maximilian Roos 8abefbce82 refactor(docs): unify AUTO-GENERATED marker form; share constants across crate boundary (#2418)
## Summary

Follow-ups from #2417 review, plus adjacent simplifications.

- **Cross-crate share of marker constants.** `MARKER_OPEN_PREFIX` and
`MARKER_CLOSE` now live in `src/docs.rs`. `src/help.rs` (the
`--help-page` producer of help-region markers) and
`tests/integration_tests/readme_sync.rs` (the consumer + producer of
snapshot/section markers) both reference them — drift becomes a build
error rather than a silent mismatch.
- **Drop `MARKER_OPEN_HTML_PREFIX`.** The `-HTML` variant emitted
visually identical wrapping; the suffix only signalled "in-place
refreshable" but the `.snap` ID + terminal-shortcode body in
`DOCS_SNAPSHOT_MARKER_PATTERN` already discriminate that. Standardised
on a single open prefix and stripped the `-HTML` literals from
`README.md`, the four standalone docs files, and the test file.
- **Help-page marker uses the shared prefix.** The mirrored close (`<!--
END AUTO-GENERATED from \`wt <cmd> --help-page\` -->`) stays as-is
because adjacent regions need unambiguous pairing, but the open prefix
now goes through `MARKER_OPEN_PREFIX`.
- **Drop `MarkerType::output_format()` / `extract_inner()`.** Both had a
single trivial non-panic branch that existed only to enforce "no
Snapshot markers in README" via `unreachable!()`. Replaced with one
explicit assertion in `sync_readme_markers` — surfaces the invariant as
an actionable error message instead of a panic.
- **Collapse `format_replacement`.** `wrap_in_marker` is invoked once
for both output formats; only body construction varies.
- **`__WT_QUOT__` rename in `tests/integration_tests/user_hooks.rs`.**
Inlined the single quotes — the `.replace('__WT_QUOT__', \"'\")` was
unnecessary indirection (the outer raw-string literal already tolerates
`'`, and TOML / Tera don't care about embedded `'`). Removes a
name-conflation footgun with the unrelated `__WT_QUOT__` placeholder in
`src/docs.rs`.

Net diff: -7 lines.

## Test plan

- [x] `cargo test --test integration` — full integration suite (1559
tests) pass
- [x] `cargo test --test integration readme_sync` — all 13 sync tests
pass; `test_readme_examples_are_in_sync` now produces a stable README on
a clean run (verified by re-running multiple times — no further updates
after the initial regeneration)
- [x] `cargo test --test integration
test_args_indexing_and_length_in_hook_template` — confirms the
user_hooks single-quote inlining works through TOML and Tera
- [x] Visual check via local Zola dev server: \`/worktrunk/\`,
\`/llm-commits/\`, \`/claude-code/\`, \`/tips-patterns/\`, \`/merge/\`,
\`/step/\`, \`/remove/\`, \`/hook/\`, \`/list/\` all render terminal
blocks cleanly with no leaked marker strings (`AUTO-GENERATED-HTML`,
`__WT_OPEN2__`, trailing `|||`)
- [x] `cargo clippy --all-targets --all-features` — clean
- [x] `cargo fmt --check` — clean

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-25 16:57:39 -07:00
Maximilian Roos 1e053d2d88 refactor(docs): fix blank-line cmd-stream corruption; share AUTO-GENERATED marker constants (#2417)
## Summary

Three small follow-ups + a placeholder rename, all from #2405 review
feedback.

- **Root-cause fix for `|||` corruption.**
`convert_dollar_console_to_terminal` was promoting blank lines inside
mixed `\$ cmd + output` blocks to extra `cmd=` entries, dropping the
blank from the rendered body and emitting a stray `\$` prompt (e.g.
`cmd=\"wt list|||\"`). Now distinguishes command-only blocks (blanks are
visual spacing in `cmd=`) from mixed blocks (blanks belong to body).
Removes the matching `trim_end_matches('|')` bandage in
`expand_command_placeholders`. New unit test in `src/docs.rs` covers the
mixed-block case.
- **Single-pass classification.** Folded the two filter chains over
`block_lines` into one loop with one match — fewer branches, no
duplicated predicates.
- **AUTO-GENERATED marker consolidation.** Within
`tests/integration_tests/readme_sync.rs`, the `<!-- ⚠️ AUTO-GENERATED
... -->` literal appeared in four producer / regex sites with subtly
different shapes (HTML vs plain, ID format). Factored into
`MARKER_OPEN_PREFIX` / `MARKER_OPEN_HTML_PREFIX` / `MARKER_CLOSE`
constants plus a `wrap_in_marker()` helper, and threaded those constants
into the regexes via `regex::escape`. Pure refactor — no docs/skill
files regenerated.
- **`__WT_OPEN2__` / `__WT_CLOSE2__` → `__WT_OPEN__` / `__WT_CLOSE__`.**
The `2` was meant to distinguish doubled-brace placeholders from
hypothetical single-brace ones, but Tera only treats `{{`/`}}` (not
single braces) as template delimiters — there's no second variant to
disambiguate from. Updated the producer (`src/docs.rs`), the test-side
decoder (`tests/integration_tests/readme_sync.rs`), the Zola template
(`docs/templates/shortcodes/terminal.html`), the docs-site `CLAUDE.md`,
and the regenerated `docs/content/{step,hook}.md`.

## Test plan

- [x] `cargo test --lib
docs::tests::test_convert_dollar_console_to_terminal` — exercises new
mixed-block case + existing command-only / multi-cmd / comment cases
- [x] `cargo test --test integration readme_sync` — 13 sync tests still
pass after the refactor and bandage removal
- [x] `cargo test --test integration` — full integration suite (1558
tests) pass
- [x] Visual check via local Zola dev server: `/merge/`, `/step/`,
`/remove/`, `/hook/`, `/llm-commits/`, and `/list/` all render terminal
blocks cleanly with no `|||` artifacts and no leaked placeholder strings
(`__WT_OPEN__` etc. don't appear in served HTML). The multi-command jq
recipes block on `/list/` continues to render comments as bash-styled
section headers.
- [x] `cargo clippy --all-targets --all-features` — clean
- [x] `cargo fmt --check` — clean

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-25 16:21:24 -07:00
Worktrunk Bot 6d0588de3c docs(faq): document new summary cache layout (#2408)
## Summary

PR #2407 added the content-addressed summary cache at
`.git/wt/cache/summary/{branch}/{hash}.json`, but the FAQ inventory in
['What files does Worktrunk
create?'](https://worktrunk.dev/faq/#what-files-does-worktrunk-create)
only listed the flat `.git/wt/cache/{kind}/*.json` shape. That row
doesn't capture the nested layout (`{branch}/{hash}.json` instead of
flat) or the LLM-summary purpose, so the on-disk surface now exceeds
what the docs admit.

Add a dedicated row matching how `wt config state get` already
surfaces summaries as a separate `SUMMARY CACHE` table.

Caught while reviewing recent commits in the nightly sweep.

## Test plan

- [x] `cargo test --test integration
test_command_pages_and_skill_files_are_in_sync` — passes (skill mirror
auto-synced)
- [ ] CI green

Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 08:45:29 -07:00
Worktrunk Bot 5f126d21fe docs(pages): add static command output to sections dominated by GIFs (#2405)
## Summary

Adds static command-output blocks to the docs pages dominated by GIFs
(addresses #2403). The blocks are **driven from insta snapshots** so
they stay in lockstep with what `wt` actually prints — one source flows
to all three surfaces:

- terminal `wt <cmd> --help` (plain text, gutter-formatted)
- `docs/content/*.md` (colorized `{% terminal(cmd="...") %}` shortcode)
- `skills/worktrunk/reference/*.md` (plain `$ cmd\noutput\n` block)

## What changed

- **Six scripted snapshot tests** produce realistic output (`cargo
nextest run`, `flyctl scale count 0`, LLM-generated commit messages):
  - `test_docs_merge_pre_merge_hook` — `wt merge` with pre-merge hook
  - `test_docs_step_commit_llm` — `wt step commit` with LLM
  - `test_docs_step_squash_llm` — three-commit squash with LLM
- `test_docs_merge_squash_llm` — `wt merge` (squash + LLM + merge) for
`llm-commits.md`
- `test_docs_remove_pre_remove_hook` — `wt remove` with pre-remove hook
  - `test_docs_hook_pre_merge` — `wt hook pre-merge` direct invocation

- **Sync pipeline extension** in
`tests/integration_tests/readme_sync.rs`:
- New write-back pass `sync_cli_mod_example_bodies` fills the
```console``` body in each `<!-- wt <cmd> (docs-example) -->`
placeholder in `src/cli/mod.rs` from the registered snapshot. Runs
before `--help-page` reads the file.
- `COMMAND_PLACEHOLDER_PATTERN` extended to match three forms
(```bash```, `{{ terminal() }}` self-closing, `{% terminal %} body {%
end %}`). This **fixes a pre-existing bug** in `docs/content/list.md`
where the HTML-mode expansion was silently broken.
- Stripped trailing `|||` corruption that arises when blank lines in
snapshot bodies are interpreted as empty commands by
`convert_dollar_console_to_terminal`.

- **Docs page migration**:
- `src/cli/mod.rs` — replaced four hand-written ```console``` blocks
(merge, step, remove, hook) with `<!-- wt <cmd> (docs-example) -->`
markers.
- `docs/content/llm-commits.md` — replaced three hand-crafted HTML
blocks with `<!-- ⚠️ AUTO-GENERATED-HTML from X.snap -->` markers.

- **Refactor follow-up** in a separate commit:
- `BADGE_EXPERIMENTAL_HTML`, `SUBDOC_MARKER_PREFIX`,
`DEMO_MARKER_PREFIX` hoisted to `worktrunk::docs` so producer and
consumer stay in lockstep.
- `normalize_clap_help_fences()` consolidates the `text→` +
`console→bash` replacement pair shared by `--help-md` and
`help_reference_inner`.

- **CLAUDE.md note** documenting the `.gitattributes`
`linguist-generated=false` exemption requirement when adding skill-only
files (carryover from #2409).

## Test plan

- [x] `cargo test --test integration readme_sync` — all 13 tests pass,
idempotent
- [x] `cargo test --test integration test_help` — help snapshots updated
- [x] `cargo run -- {merge,remove,step,hook} --help` — clean gutter
formatting, no visible HTML comments
- [x] `cargo run -- hook pre-merge --yes` — full project gate green
(3355 tests)
- [ ] Visual check on dev site (maintainer — terminal shortcodes now
render with colors via ANSI→HTML; dark/light variants both expected to
look like the GIFs they replace)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Maximilian Roos <m@maxroos.com>
2026-04-24 16:40:21 -07:00
Maximilian Roos 89dceed12e docs(site): add llms.txt + .md companions (#2404)
## Summary

Implements the [llms.txt spec](https://llmstxt.org/) for the docs site:

- **`worktrunk.dev/llms.txt`** — curated index listing every doc page,
grouped by sidebar section (Commands, Reference) and ordered by weight.
Generated from `docs/content/*.md` front-matter.
- **`worktrunk.dev/<page>.md`** — clean-markdown versions of each doc
page, served as symlinks from `docs/static/*.md` into
`skills/worktrunk/reference/*.md`. Zola follows the symlinks at build
time, so there's no content duplication — just 13 symlink entries in
git, alongside the existing
`docs/static/.well-known/agent-skills/worktrunk` symlink.

## What changed

- 13 symlinks at `docs/static/*.md` →
`../../skills/worktrunk/reference/*.md`
- `docs/static/llms.txt` — generated output (checked in, same as
`skills/worktrunk/reference/` and `.well-known/agent-skills/index.json`)
- `sync_llms_txt()` + `extract_intro_prose()` +
`docs_content_page_names()` helpers in
`tests/integration_tests/readme_sync.rs`
- Step 4 added to `test_command_pages_and_skill_files_are_in_sync` — CI
catches drift if front-matter changes and `llms.txt` isn't regenerated

The new directory-walk helper (`docs_content_page_names`) is shared with
`sync_skill_files`, which had the same walk inlined.

## Test plan

- [x] `cargo test --test integration readme_sync` — 13/13 pass,
idempotent on re-run
- [x] `cargo fmt` + `cargo clippy --all-targets --all-features -- -D
warnings` — clean
- [x] `pre-commit run --files tests/integration_tests/readme_sync.rs` —
pass
- [x] `zola build` produces `public/merge.md` etc. as real 4814-byte
files (not symlinks) and `public/llms.txt`
- [ ] CI green

> _This was written by Claude Code on behalf of @max-sixty_

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
2026-04-23 17:33:21 -07:00
Maximilian Roos 1649bb528b Release v0.44.0 2026-04-22 08:48:29 -07:00
Maximilian Roos 4874aeda94 Release v0.43.0 2026-04-21 14:39:42 -07:00
Worktrunk Bot 3a9d6402cb docs(hook): clarify when pre-start/post-start fire (#2360)
## Summary

Refs #1571. Adjusts the existing `pre-start` and `post-start` rows in
the hook types table to say when the hooks fire, rather than only what
to put in them.

Before:

```
| `pre-start` | Tasks that must complete before `post-start`/`--execute`: dependency install, env file generation |
| `post-start` | Dev servers, long builds, file watchers, copying caches |
```

After:

```
| `pre-start` | Runs once when a new worktree is created, blocking `post-start`/`--execute` until complete: dependency install, env file generation |
| `post-start` | Runs once when a new worktree is created, in the background: dev servers, long builds, file watchers, copying caches |
```

Matches the phrasing of neighbouring rows (e.g. `pre-switch` has \"Runs
before...\", `post-merge` has \"Runs in the target...\"). No new section
or explanation added — the adjustment is confined to the two rows whose
definitions were silent on timing.

Per @max-sixty's ask in
https://github.com/max-sixty/worktrunk/issues/1571#issuecomment-4291227117,
marked as draft for review.

## Test plan

- [x] `cargo test --test integration
test_command_pages_and_skill_files_are_in_sync` — passes after
regenerating `docs/content/hook.md` and
`skills/worktrunk/reference/hook.md`
- [x] `cargo insta test --accept -- --test integration test_help` — no
snapshot changes (the row is inside the table rendered from
`after_long_help`; existing help snapshots already absorbed the old
wording in their expected forms but don't anchor on it)

Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 13:48:58 -07:00
Worktrunk Bot ea505bebf9 fix(step): gate copy-ignored self-lower on WORKTRUNK_FOREGROUND=-1 (#2358)
## Summary

Addresses #2342 — `wt step copy-ignored` has been much slower on macOS
since v0.37.0 because `taskpolicy -b` throttles disk I/O, and that hits
foreground callers (interactive use and `pre-*` hooks) as well as
background ones.

Per the [design proposed in the
issue](https://github.com/max-sixty/worktrunk/issues/2342#issuecomment-4290580468):

- Export `WORKTRUNK_FOREGROUND=-1` when spawning a background hook
pipeline (detached `wt hook run-pipeline`). The env var is inherited by
every descendant — shell, user command, nested `wt` invocations.
- `wt step copy-ignored` self-lowers only when it sees that sentinel;
interactive runs and synchronous `pre-*` hooks run at normal priority.
- Documented in `wt step copy-ignored --help` under a "Background-hook
priority (experimental)" section. Variable name and value flagged as
not-yet-stable.

Surface area:

- `src/priority.rs` — new `FOREGROUND_ENV_VAR` / `BACKGROUND_HOOK_VALUE`
consts and `in_background_hook()` helper, with a testable inner fn
(`is_background_hook_value`) so we can unit-test the match without
mutating process-global env.
- `src/commands/process.rs` — `spawn_detached_exec` now sets the env var
on the detached runner when the log variant is `HookLog::Hook`. Internal
ops (removal, trash-sweep) don't set it.
- `src/commands/step_commands.rs` — `copy-ignored` gates the existing
`lower_current_process()` call on `in_background_hook()`.
- `src/cli/step.rs` + auto-synced docs — experimental note, and removed
a now-stale paragraph that claimed unconditional `nice 19`.
- Integration test verifies `pre-start` sees the var unset and
`post-start` sees `-1`.

## Test plan

- [x] `cargo test --lib --bins` — 601 passed
- [x] `cargo test --test integration
test_background_hook_sees_worktrunk_foreground_env_var` — passes
- [x] `cargo test --test integration step_copy_ignored` — 44 passed
- [x] `cargo test --test integration user_hooks` — 106 passed
- [x] `cargo test --test integration
test_command_pages_and_skill_files_are_in_sync` — passes (docs
auto-synced)
- [x] `cargo test --test integration test_help` — 40 help snapshots pass
- [x] `cargo clippy --all-targets` — clean

---------

Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 11:58:35 -07:00
Maximilian Roos 771918f29e docs(hook): promote Recipes to top-level H1 (#2351)
Follow-up to #2349. With the old "Designing Effective Hooks" umbrella
heading removed, `## Recipes` was landing at H2 under `# Running Hooks
Manually`, reading as a sub-topic of manual invocation — which it isn't.
Promoting to `# Recipes` makes it a peer of the other top-level sections
(`# Hook Types`, `# Security`, `# Configuration`, `# Running Hooks
Manually`).

Trailing `## See also` stays at H2 — consistent with how other command
docs handle their "See also" footers.

> _This was written by Claude Code on behalf of Maximilian_

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-20 22:15:14 -07:00
Maximilian Roos 970375afd4 docs(hook): restructure recipes list and callout for copy-ignored (#2349)
Three tweaks to the hook docs:

- Drop the "Designing Effective Hooks" heading and rename "More recipes"
to "Recipes".
- Make each recipe bullet lead with a specifically-named link to its
Tips & Patterns section, so the list functions as a table of contents
rather than a paragraph with a trailing URL.
- Move "Copying untracked files" up next to the JSON context section and
frame it as a specific command worth calling out, rather than leaving it
as a lone sibling of "Recipes" under the old umbrella heading.

Source is `after_long_help` in `src/cli/mod.rs`; `docs/content/hook.md`
and `skills/worktrunk/reference/hook.md` are regenerated by the sync
test. Dead post-processor entries in `src/help.rs` that used to convert
the old bare-URL bullet format are removed.

> _This was written by Claude Code on behalf of Maximilian_

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-20 21:40:18 -07:00
Maximilian Roos a24e9577a2 Release v0.42.0
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:37:25 -07:00
Maximilian Roos 387630850d docs(skill): add non-interactive hook approval guidance (#2343)
Agents using the canonical \`worktrunk\` skill hit the hook-approval
prompt error when running \`wt merge\` (or any command that runs project
hooks) in a non-interactive session. The error already names both escape
hatches — \`wt config approvals add\` and \`--yes\` — but without
context, an agent may not know which to pick, or may silently default to
\`--yes\` when the user would rather pre-approve once.

New section in `skills/worktrunk/SKILL.md` covers:
- The error signature
- `wt config approvals add` — interactive, persists to
`~/.config/worktrunk/approvals.toml` until the template changes or the
project moves
- `--yes` — single-invocation bypass for CI/CD
- Directs agents to escalate rather than auto-`--yes`, since
pre-approval is a trust decision

Verified `wt config approvals add --help` and `wt config approvals
--help` match what the doc describes.

> _This was written by Claude Code on behalf of @max-sixty_

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-20 13:27:02 -07:00
Maximilian Roos 6ba5611415 Release v0.41.0 2026-04-20 11:37:18 -07:00
Maximilian Roos 86f4b37e48 docs(help): rename '[Aliases]' link to 'Extending Worktrunk guide' (#2330)
The bare `[Aliases](@/extending.md#aliases)` link text rendered in
terminal help as "See Aliases for ..." — a self-reference when sitting
inside a section already titled "Aliases" (`wt config user/project
--help`), and a generic label elsewhere (`wt config alias --help`).
Renamed to `[Extending Worktrunk guide]` in all three call sites.

Also adds a short guideline under "Help text authoring" in CLAUDE.md:
link text must stand alone when the URL is stripped (since terminal help
strips URLs and keeps only the text).

> _This was written by Claude Code on behalf of Maximilian_

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-20 00:41:12 -07:00
Maximilian Roos f0152a5a0d docs(hook): consolidate pipeline forms into one section (#2333)
The \`# Configuration\` intro introduced the string/table/pipeline
forms, and a later \`# Pipeline Ordering [experimental]\` section
re-explained the same three forms with different examples.

Merge them under a single \`## Hook forms\` subsection inside \`#
Configuration\`, keep the "when to use \`[[hook]]\`" guidance, and drop
the stale \`[experimental]\` tag. Update the two \`#pipeline-ordering\`
anchor links in \`extending.md\` to \`#hook-forms\`.

Net: 6 files, -137 lines. No behavior change — help output unchanged (no
snapshot updates needed).

> _This was written by Claude Code on behalf of Maximilian Roos_

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-20 00:40:41 -07:00
Maximilian Roos 18825c3e3c docs(hook): move progressive-validation and target-specific examples to tips-patterns (#2329)
Keeps "Copying untracked files" inline (the one example that materially
uses worktree-specific mechanics) and moves the two plain-TOML patterns
— progressive validation and target-specific `post-merge` — to
`tips-patterns.md`. `hook.md` references them as `More recipes` bullets
instead.

> _This was written by Claude Code on behalf of Maximilian._

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-20 00:21:16 -07:00
Maximilian Roos 1a0a3e2f83 Widen Zola link regex to tolerate code spans in link text (#2327)
`ZOLA_LINK_PATTERN`'s link-text class `[^\]]+` rejected any `]`, so Zola
links whose text contained a code span with `]]` (e.g. `` `[[block]]`
``) never matched and shipped as dead `@/…md` references in skill files.
#2321 worked around two such links by rewording them — this fixes the
root cause and restores the original wording.

## The regex change

Alternate "a balanced `` `…` `` code span" with "any
non-`]`-non-backtick char" inside the link text:

```
\[((?:`[^`]*`|[^\]`])+)\]\(@/([^)#]+)\.md(#[^)]*)?\)
```

Forbidding bare backticks in the single-char branch is load-bearing:
without it, the regex can bridge across two unrelated code spans on the
same line. `docs/content/faq.md:63` is a live example — ``
[`worktrunk-sync`](https://github.com/…) `` followed much later by ``
[custom subcommands](@/extending.md#custom-subcommands) ``. With a
permissive single-char branch, the engine pairs backtick 2 with backtick
3 (spanning the `]` after `worktrunk-sync`) and matches the whole chunk
as one "link."

## Guardrail

Added an `UNTRANSFORMED_ZOLA_LINK_PATTERN` check in
`finalize_skill_content`. After the transform, any leftover `](@/…md)`
panics the sync test with the offending line — so a future regex miss
fails loudly instead of silently shipping a dead link.

## Doc restoration

Reverts the two rewordings from #2321 in `docs/content/extending.md`
(`[[block]]` now sits inside the link text again).

## Testing

`cargo test --test integration readme_sync` (12 tests) passes. Full
`cargo run -- hook pre-merge --yes` green locally (3291 tests, all
lints).

> _This was written by Claude Code on behalf of Maximilian Roos_

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-19 23:57:48 -07:00
Maximilian Roos 0be0a28eae docs(hook): drop pre-start vs post-start section (#2326)
The section mostly restated content already covered by the "Hook Types"
table and the hook purpose table above it. Removing it and folding the
load-bearing guidance ("prefer `post-start` unless a later step needs
the work completed first") into the existing "most common starting
point" sentence near the top.

> _This was written by Claude Code on behalf of Maximilian_

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-19 23:50:53 -07:00
Maximilian Roos 064f8940c0 docs(hook): consolidate copy-ignored recipe and trim redundant examples (#2323)
Docs consolidation sweep, same pattern as #2319.

**Copy-ignored recipe** — collapses hook.md's "Copying untracked files"
overlap with tips-patterns.md into a "More recipes" bullet pointing at
the canonical recipe. Keeps a one-paragraph intro in hook.md (worktrees
don't share untracked files → use `wt step copy-ignored`) since it's a
fundamental worktree concept. Moves the concrete pnpm pipeline example
into tips-patterns.md's canonical recipe, and switches it from
`[[pre-start]]` to `[[post-start]]` — the pipeline form handles ordering
without blocking worktree creation. `pre-start` is now called out only
as the exception for when `--execute` needs the files immediately.

**Trims**
- Dropped hook.md's "Hook type examples" dump: most of its 10 entries
duplicate existing recipes (dev server, database, cold starts) or the
Progressive validation section.
- Dropped tips-patterns.md's "Local CI gate": four-line recipe that
duplicated hook.md's Progressive validation and merge.md's Local CI
narrative.
- Dropped hook.md's "Python virtual environments" snippet: step.md
already owns language-specific notes.

> _This was written by Claude Code on behalf of Maximilian Roos_

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-19 23:47:12 -07:00
Maximilian Roos 6e5c667c3d fix(docs): style tables nested in <details> blocks (#2325)
The table CSS used `.content > table`, which skipped tables inside
`<details>` and any other wrapper — they fell back to browser defaults
(no borders, 1px padding, mid-aligned cells). Most visibly, the
"Interface differences" table on `/extending/` looked unstyled.

Switched to a descendant selector for both the main rule and the print
rule so nested tables pick up the same styling.

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-19 23:43:41 -07:00
Maximilian Roos 0253260503 Extend -v variable dump to aliases + help-table drift test (#2324)
Follow-ups from #2316.

## What's in here

1. **Alias `-v` variable dump.** Added `format_alias_variables(ctx)`
alongside the existing `format_hook_variables(hook_type, ctx)`, with a
private `format_variables_table` helper sharing the alignment +
`(unset)` logic. Wired into `run_alias` before the announcement,
symmetric with the foreground hook path.

2. **Help-table drift test.**
`test_template_variables_table_matches_constants` parses the `##
Template variables` table out of `src/cli/mod.rs`, extracts `(kind,
var_name)` pairs, and asserts presence + group placement against
`ACTIVE_VARS` / `REPO_VARS` / `EXEC_BASE_VARS` / `ALIAS_ARGS_KEY` /
union of `vars_available_in(Hook(*))`. Uses public API only — no leak of
the private `hook_extras` helper. Adding a var to the constants without
updating the table (or vice versa) fails the test. Descriptions stay
free-form.

3. **Shorter `-v` help text.** `Verbose output (-v: info logs +
hook/alias template variable & output; ...)`.

## Example

```
\$ wt -v greet world
○ template variables:
  branch                = feature
  worktree_path         = _REPO_.feature
  worktree_name         = repo.feature
  …
  args                  = ["world"]
  repo                  = repo
  …
  cwd                   = _REPO_.feature
◎ Running alias greet
○ Expanding greet
  echo hello {{ args }}
  →
  echo hello world
hello world
```

> _This was written by Claude Code on behalf of @max-sixty_

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 23:39:29 -07:00
Maximilian Roos d70e2f2e98 Clarify template engine scope in extending docs (#2321)
Tighten the extending docs around the template engine and pipeline
syntax shared by hooks and aliases. Link to `wt hook`'s
template-variables, filters, functions, pipeline-ordering, and
passing-values sections so binding rules and `[[block]]` semantics are
documented in one place rather than duplicated. Remove the redundant
`--KEY=VALUE` / variable-override / hyphen-to-underscore explanations
from the aliases section.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-19 23:34:43 -07:00
Maximilian Roos 56d7bb825d fix(docs): exclude alternate pages from sitemap and add trailing slashes to nav (#2320)
Search Console flagged two indexing issues on worktrunk.dev:

**"Alternate page with proper canonical tag"**: the sitemap listed
`/worktrunk/`, which canonicalizes to `/` (its content is rendered on
the homepage via `templates/index.html`). Only canonicals belong in a
sitemap. Skip sitemap entries whose frontmatter declares a canonical.

**"Page with redirect"**: the `extra_menu` links `/worktrunk` and
`/tips-patterns` had no trailing slash, so every page's nav triggered a
301 on GitHub Pages. Added the trailing slashes.

Verified locally with `zola build`: sitemap goes from 14 to 13 entries
(no more `/worktrunk/`), `/worktrunk/` page still served with canonical
= `/`, and built nav hrefs now have trailing slashes.

Search Console also reported "Not found (404)" — those are old renamed
URLs (`/why-worktrunk/`, `/quickstart/`, `/advanced/`) that no longer
exist. Left alone here: a static 404 is correct, and adding meta-refresh
redirect stubs would just create new "Page with redirect" entries.

> _This was written by Claude Code on behalf of @max-sixty_

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-19 22:38:15 -07:00
Maximilian Roos df448861bc docs(hook): link dev-server/database recipes out to tips-patterns (#2319)
The Dev servers and Databases sections in `hook.md` duplicated nearly
identical TOML and narration from the corresponding recipes in
`tips-patterns.md`. Replaces them with two bullets under a new "More
recipes" section that link to the canonical recipes, and redirects
inbound links (`README.md`, `worktrunk.md` feature list) from
`/hook/#dev-servers` to `/tips-patterns/#dev-server-per-worktree`.

The bullets use bare URLs in `cli.rs` so `wt hook --help` shows
terminal-auto-linkable `https://...` rather than stripped markdown link
text; `post_process_for_html` rewrites them to inline markdown links for
the web docs. Same pattern as the existing "Open an issue at ..."
transform.

Also tightens the extending.md note on operation-context variables to
"aren't auto-populated" — they can still be bound via `--KEY=VALUE` on
the CLI.

Net −156 lines.

> _This was written by Claude Code on behalf of Maximilian_

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-19 22:36:08 -07:00
Maximilian Roos eec3b24883 Show resolved template variables under -v for hooks (#2316)
Closes #2309.

When a hook fires under `-v`, `wt` now prints a `template variables:`
block listing every variable in scope for that hook type and the value
it resolved to for this specific invocation. Vars that are in scope but
not populated render as `(unset)` — which is exactly how
`target_worktree_path` surfaces during `wt switch -`, the thing the
issue reporter hand-rolled an echo-hook to discover.

The block prints *before* the `◎ Running …` announce line so it
describes what the hook is about to see, not what already ran. Works in
both paths: the foreground path (`announce_command`, one block per
command) and the background path (`announce_and_spawn_background_hooks`,
one block per distinct hook type in the pipeline batch).

## Key files

- `src/config/expansion.rs` — `format_hook_variables(hook_type, ctx)`
emits the aligned `name = value` block ordered per the docs table
(active → operation → repo → exec). `BASE_VARS` is split into
`ACTIVE_VARS` / `REPO_VARS` / `EXEC_BASE_VARS` with a `base_vars()`
helper, so `vars_available_in` and the printer share one source of
truth.
- `src/commands/command_executor.rs` — foreground wiring in
`announce_command`, gated on `verbosity() >= 1`.
- `src/commands/hooks.rs` — background wiring in
`announce_and_spawn_background_hooks`; prints one table per distinct
hook type (since within a pipeline only `hook_name` varies).
- `src/cli/mod.rs` — updates the global `-v` help text and adds a
pointer under `## Template variables` in the hook help.

## Testing

- Unit: `test_format_hook_variables_groups_and_unset` snapshots a
pre-switch context with `target_worktree_path` omitted so `(unset)` is
covered; `test_format_hook_variables_scope_filters_operation` confirms
pre-commit's narrower operation scope.
- Integration: `test_hook_verbose_prints_variable_table` covers the
foreground path; the existing
`test_post_start_verbose_shows_per_hook_output` now also exercises the
background path.

No new CLI surface, no new config keys — only behavior added behind the
existing `-v` flag.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 22:28:43 -07:00
Maximilian Roos 566f399331 docs: explain why aliases and hooks render --help differently (#2318)
Hooks inject stub subcommands into the Command tree so `wt hook --help`
and `wt hook <type> --help` render real clap output. Aliases don't —
their argument parsing is template-driven (smart routing based on which
vars the template references), `--dry-run` is rejected, and post-alias
`--yes` forwards to `{{ args }}`, so a clap stub would misrepresent
them. The help path text-splices an `Aliases:` block into `wt --help` /
`wt step --help` and redirects `wt <alias> --help` to `wt config alias
show` / `dry-run` instead.

Nothing actually changes. This PR surfaces the reason in the two doc
comments next to the relevant functions (`inject_alias_subcommands`,
`augment_help`), and rewrites the `--help` row of the `extending.md`
"hooks vs. aliases" reference table in user-facing terms — the previous
wording leaked "clap-rendered" as user-visible jargon.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-19 22:20:19 -07:00
Maximilian Roos 86731e8cc2 docs(extending): refine alias recipes and template prose (#2317)
## Summary

- Tighten the **move-changes** and **hook-log** alias recipes; cut the
"actual branch doesn't move" aside on template variable overrides.
- `move-changes` now forwards tokens after `--` into the new worktree
via `--execute`, so `wt move-changes --to=feature-xyz -- claude` pops
the stash and opens Claude there.
- `hook-log` takes `--kind` so it isn't locked to `post-start`; prose
clarifies that `--name` is the TOML hook key and branch is pulled from
the current worktree.
- Reword the arg-escaping paragraph to explain *why* shell-escaping
matters (spaces don't split, `;` doesn't terminate), and clarify that
`--` forwards tokens literally into `{{ args }}`.

## Test plan

- [x] \`cargo run -- hook pre-merge --yes\` (3283 passed, lints clean)
- [x] \`cargo test --test integration
test_command_pages_and_skill_files_are_in_sync\`
- [x] Verified \`wt move-changes --to=foo -- claude\` renders
\`--execute="git stash pop --index; claude"\` via \`wt config alias
dry-run\`

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-19 22:20:17 -07:00
Maximilian Roos a77edaf0a8 docs(extending): consolidate overlap with hook.md and within aliases (#2315)
The Hooks section of \`extending.md\` largely restated \`hook.md\` —
event table, pre-/post- rule, user/project split, three TOML forms,
variable teaser, and common patterns all duplicated. The Hooks and
Aliases sections within the page also repeated each other on the
\`[[block]]\` pipeline syntax, approval model, and the core variable
list.

Cuts:

- Added a one-paragraph shared-concepts note after the comparison table
covering TOML config, the template engine link, \`[[block]]\` semantics,
and the approval model.
- Trimmed \`## Hooks\` to the event table, the pre-/post- rule, one
flavor example, and a pointer to \`hook.md\`.
- Trimmed \`### Templates\`, \`### Multi-step pipelines\`, and the
former \`### Sources and approval\` in \`## Aliases\` to what's
alias-specific.

Renames / rewordings:

- \`### Sources and approval\` → \`### Changing directory\`. Expanded to
note that \`wt switch\`, \`wt merge\` (leaving the removed source), and
\`wt remove\` of the current worktree all propagate cd via shell
integration — but other shell state (\`cd\`, \`export\`) doesn't persist
because the alias runs in a subshell.
- Link text \"hook template engine\" → \"variable and filter reference\"
so the template system reads as shared rather than hook-owned.

Net: \`docs/content/extending.md\` 246 → 189 lines (−57).
\`skills/worktrunk/reference/extending.md\` auto-synced.

> _This was written by Claude Code on behalf of @max-sixty_

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-19 21:51:26 -07:00
Maximilian Roos e4fab6c791 feat(hook): unify hook argument syntax with alias smart routing (#2313)
## Summary

Hooks and aliases now share a single mental model for CLI variable
binding: `--KEY=VALUE` binds `{{ KEY }}` if the template references it,
else forwards to `{{ args }}`. Tokens after `--` forward
unconditionally. `{{ args }}` is now available in hook templates.

Internally, the 11 duplicated hook clap variants collapse into a single
`#[command(external_subcommand)]` mirroring the alias pattern, with
completion and help injection grafting hook-type stubs onto the
augmented `Command` tree.

- `--var KEY=VALUE` still works but emits a deprecation warning pointing
at `--KEY=VALUE`.
- `wt hook --help` lists all hook types (via help-tree injection).
- New `## Reference: hooks vs. aliases` section in
`docs/content/extending.md` documents remaining interface differences.

## Test plan

- [x] `cargo run -- hook pre-merge --yes` (all tests + lints, 3283 tests
passing)
- [x] `wt hook pre-merge --branch=foo --yes` — smart-binds when
referenced
- [x] `wt hook pre-merge --KEY=VALUE --yes` — forwards to `{{ args }}`
when not referenced
- [x] `wt hook pre-merge -- --extra --yes` — post-`--` tokens forward
unconditionally
- [x] `wt hook pre-merge --var branch=foo --yes` — deprecation warning +
force-bind
- [x] `wt hook --help` shows all 10 hook types
- [x] Shell completion: `wt hook <TAB>` lists hook types

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-19 21:38:42 -07:00
Maximilian Roos 31b9b35103 docs(alias): clarify which template variables are available in aliases (#2314)
The aliases section of `extending.md` claimed "same template variables
as hooks" — that's wrong. Aliases get `BASE_VARS` + `args` +
`vars.<key>`, but not hook operation-context vars (`target`, `base`,
`pr_number`, `pr_url`, `target_worktree_path`, `base_worktree_path`) or
infrastructure vars (`hook_type`, `hook_name`). A user writing `{{
target }}` in an alias would hit an undefined-value error with no
pointer to what actually works.

Replaces the false equivalence with an explicit enumeration under a new
`### Templates` subsection — listing the variables that do work and
noting that operation-context vars aren't populated since there's no
operation in progress. Also renames the adjacent `### Forwarding
positional arguments` → `### Positional arguments` so the two headings
pair cleanly as siblings.

Docs-only. The synced skill mirror
(`skills/worktrunk/reference/extending.md`) picks up the same edits via
`test_command_pages_and_skill_files_are_in_sync`.

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-19 21:10:21 -07:00
Maximilian Roos d715753cf0 Release v0.40.0 2026-04-19 14:55:42 -07:00
Maximilian Roos 68229ce571 Smart routing for alias arguments + dry-run routing display (#2304)
Routes `--KEY=VALUE` tokens based on what the template references.
Tokens whose `KEY` appears as `{{ KEY }}` in the alias bind to that
variable; everything else joins `{{ args }}`. Space form `--KEY VALUE`
is equivalent.

Key changes:
- Parser uses template reference introspection (via
`referenced_vars_for_config`) to decide binding at parse time.
- Post-alias `--yes` retired — use `wt -y <alias>` (the global form)
instead. `--dry-run` on an alias invocation is also retired; `wt config
alias show/dry-run <name>` replaces it (landed separately in #2291).
- Hyphens in keys canonicalize to underscores, so `--my-var=x` binds `{{
my_var }}`.
- `--` is a literal-forward escape — everything after it forwards to `{{
args }}` regardless of bindings.
- `wt <alias> --help` / `-h` prints a hint pointing at `wt config alias
show/dry-run` rather than silently forwarding the flag into `{{ args
}}`. `wt <alias> -- --help` still forwards.

Advisory warnings (printed on stderr, don't affect execution):
- `--KEY VALUE` with `--`-prefixed VALUE: almost always a typo where
`--KEY=VALUE` was meant.
- `wt config alias show`/`dry-run` on a name that shadows a top-level
built-in (e.g. `list`): the alias is unreachable via `wt <name>`.

`wt config alias dry-run` now prints `# bound:` and `# args:` routing
comments above the rendered command so users can see how each token was
interpreted.

Docs rewrite of the aliases section: concrete fly preview-env example,
"Passing values" section (renamed from the opaque "How arguments are
routed"), routing mechanism moved above the introspection tools, `up`
rebase recipe restored, `since-main` alias added.

## Test plan
- [x] `cargo test --lib --bins commands::alias::tests` — unit tests
(new: duplicate-key precedence, footgun warning, multi-`=` value)
- [x] `cargo test --test integration step_alias` — integration tests
(new: shadow warning on `show` and `dry-run`, `--help` intercept,
retired `--dry-run` via `wt step`)
- [x] `cargo test --test integration
test_command_pages_and_skill_files_are_in_sync` — skill file sync
- [x] `cargo run -- hook pre-merge --yes` — full test suite + lints
green

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Worktrunk Bot <w@worktrunk.dev>
2026-04-19 14:39:07 -07:00
Maximilian Roos 0639b6bf0e docs(hook): group template variables by kind and align ordering (#2303)
## Summary

The hook template-variable surface had drifted: `BASE_VARS`,
`hook_extras`, and the user-facing help table each used a different
order. After #2300 added `pr_number`/`pr_url`, that drift got worse —
the new vars landed at the bottom of the help table next to hook
infrastructure, even though semantically they're operation context (they
travel with `base`/`target`, populated by the same `pr:N`/`mr:N` code
path).

This PR reorganises around five semantic groups and applies the same
ordering everywhere:

| Kind | Vars |
|------|------|
| `active` | branch, worktree_path, worktree_name, commit, short_commit,
upstream |
| `operation` | base, base_worktree_path, target, target_worktree_path,
pr_number, pr_url |
| `repo` | repo, repo_path, owner, primary_worktree_path,
default_branch, remote, remote_url |
| `exec` | cwd, hook_type, hook_name |
| `user` | vars.\<key\> |

## Changes

- `BASE_VARS` reordered into active → repo/remote → exec(`cwd`), with a
doc-comment pointing at the help table as the canonical order.
- `hook_extras` doc-comment requires each arm to be a prefix-ordered
subset of the operation-context block.
- Help table gains a `Kind` column printed once per group (blank on
continuation rows). `pr_number`/`pr_url` move up to `operation`; `cwd`
moves down to `exec`; the word "Active" is dropped from the first six
descriptions since `Kind` now carries that signal.
- Auto-generated docs (`docs/content/hook.md`,
`skills/worktrunk/reference/hook.md`) re-synced by
`test_command_pages_and_skill_files_are_in_sync`.

No behaviour change — ordering only, plus the new Kind column.

## Follow-ups (not in this PR)

Add a test that enforces alignment across the three sites — the
help-table row order, `BASE_VARS`, and each `hook_extras()` arm must all
be prefix-subsets of one canonical ordered list. Would prevent the drift
that #2300 introduced.

## Test plan

- [x] `cargo run -- hook pre-merge --yes` — clippy + 3272 tests +
doctests + `RUSTDOCFLAGS=-Dwarnings cargo doc`
- [x] `test_command_pages_and_skill_files_are_in_sync` passes
- [x] `test_help` rstest suite passes (no changes — it doesn't cover `wt
hook --help`)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 11:32:34 -07:00
Maximilian Roos 68ee0a327b feat(hook): expose pr_number and pr_url to PR/MR worktree hooks (#2300)
## Summary

`pr_number` and `pr_url` are now first-class template variables for
hooks running on PR/MR-created worktrees (`wt switch pr:N` / `mr:N`).
Previously the ForkRef code path injected `pr_*` (GitHub) or `mr_*`
(GitLab) extras into the pre-start template context, but those names
weren't in the validation allowlist — any user hook referencing them was
rejected before the hook could run, so the feature was unreachable.

This PR canonicalizes on a single pair (`pr_number`/`pr_url`) for both
platforms, threads it through the validation scope, plumbs it into
post-switch and post-start hooks via `SwitchResult::Created`, and
documents it in CLI help (which auto-syncs to docs and skill reference).

## Notable decisions

- **One canonical pair, not two.** GitHub and GitLab both populate
`pr_number`/`pr_url`; no `mr_*` aliases. Keeps the template surface
low-cardinality.
- **Symmetric across pre/post.** The `hook_extras` table accepts
`pr_number`/`pr_url` for `pre-switch`/`post-switch` and
`pre-start`/`post-start`. Pre-switch never actually populates them (PR
resolution hasn't happened yet at that point), but grouping pre/post
pairs matches the existing `base`/`target` convention and avoids a
one-off scope arm.
- **Threaded through `SwitchResult::Created`.** Earlier drafts only
wired pre-start; post-switch and post-start were silently dropping the
data. The Option fields on `SwitchResult::Created` carry it forward to
background hooks via `switch_extra_vars`.

## Test coverage

- `test_validate_template_scope_rejects_out_of_scope_vars` — accepts
`pr_number`/`pr_url` for pre-start, rejects for pre-merge.
- `test_switch_pr_hooks_see_pr_vars` — fork-PR scenario with mocked
`gh`; pre-start, post-start, and post-switch hooks each write a marker
file and the test asserts all three observe `pr_number=42 pr_url=...`.

## Drive-by fix: stub cargo in --source flag test

Last commit replaces `cargo run --bin wt` in
`test_source_flag_forwards_errors` with a stub-cargo shell script that
execs the existing wt binary directly. Real cargo unlinks and re-links
`target/debug/wt` on every invocation (~3.9% non-existence window
measured locally), racing against any concurrent test that
`spawn(target/debug/wt)` and producing the long-standing ENOENT flake in
`test_wrapper_switch_with_hooks` on Linux CI. Same end-to-end coverage
of the `--source` branch; runs in 2s instead of 30–60s.

> _This was written by Claude Code on behalf of Maximilian Roos_

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-19 10:57:58 -07:00
Maximilian Roos 58795e5334 docs(skill): expand worktrunk description with lexical triggers (#2301)
The prior description relied on quoted example phrases ("configuring
hooks", "automating tasks") that rarely match real user phrasing, and
never mentioned `wt` — the CLI name users actually type. I recently
skipped loading this skill while editing
`~/.config/worktrunk/config.toml` to add a post-merge push hook, which
prompted the update.

New description drops the quoted phrases and uses direct trigger
criteria: both config file paths, explicit hook event names
(`post-merge`, `post-start`, `pre-commit`, `pre-merge`, `post-switch`),
and task verbs (adding, modifying, debugging).

> _This was written by Claude Code on behalf of Maximilian_

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-18 16:02:33 -07:00
Maximilian Roos c7b94a7276 feat(config): add wt config alias show and dry-run subcommands (#2291)
## Summary

Third PR in the series simplifying alias-control flags. Introduces `wt
config alias` as the home for alias introspection and removes the
`--dry-run` flag that lived on alias dispatch.

- **`wt config alias show <name>`** — prints the configured template,
source-labeled (user/project). Each step in a pipeline is rendered in a
single gutter block with `# <step-name>` comment lines above named
steps.
- **`wt config alias dry-run <name> [-- args...]`** — parses the
arguments with the same parser `wt <alias>` uses, renders templates
against the execution-time context, and prints without running. Args
after `--` are forwarded verbatim, so `wt config alias dry-run s --
target-branch` previews exactly what `wt s target-branch` would run.
Same layout as `show`; only the header verb differs (`:` vs ` would
run:`).
- **`--dry-run` removed** from `AliasOptions` — both `wt <alias>
--dry-run` and `wt step <alias> --dry-run` now return an actionable
error pointing at the new subcommand. No deprecation period: top-level
alias dispatch is recent, so cutover is acceptable.
- **Tab-completion** completes alias names under `show` / `dry-run`.

## Output format

```
○ Alias deploy (project) would run:
 ┃ # install
 ┃ npm install
 ┃ # build
 ┃ npm run build
```

The `○ Alias <name> (<source>)` header matches the existing
`info_message` style. Bolding the name (what the user typed) and tagging
the source in parens keeps the invocation identifier visually primary.
When both user and project define the same alias, both entries print
back-to-back (user first, matching runtime execution order).

## Design notes

- The new subcommand reuses `AliasOptions::parse` as the source of truth
for invocation parsing, so preview stays aligned with runtime. When user
and project configs both define the alias, `referenced_vars` is unioned
across entries so a flag binds if any template references it.
- Templates referencing `vars.*` are shown unexpanded, mirroring the
lazy execution path — those values are read from git config just before
each step runs, potentially after earlier steps have set them. Syntax
errors still surface up front.
- `wt step <alias>` also breaks since both paths share
`AliasOptions::parse`. That's fine — `wt step <alias>` has no
forward-compat promise beyond the basic call.
- `AliasSource` is promoted to `pub(crate)` with a `label()` helper so
`wt config alias` and the alias runtime code read from one enum.

## Navigating the diff

- `src/cli/config.rs` — adds `ConfigAliasCommand` + the `Alias` variant
on `ConfigCommand`.
- `src/commands/config/alias.rs` — new module. `handle_alias_show` and
`handle_alias_dry_run` share a `format_entry` helper; the `verb:
Option<&str>` parameter is the only difference between show and dry-run
output. `render_preview` replaces the old `render_for_dry_run` in alias
dispatch.
- `src/commands/alias.rs` — the `--dry-run` flag and its rendering
branch are gone. The parser bails on `--dry-run` with a migration
message pointing at the new subcommand; the bail sits after the `--`
literal-mode check, so `wt alias -- --dry-run` still forwards as
positional.
- `src/completion.rs` — `alias_name_completer()` mirrors the existing
`hook_command_name_completer` pattern.
- `tests/integration_tests/step_alias.rs` — existing `--dry-run` tests
migrate to `wt config alias dry-run`, plus new coverage for `show`,
unknown-alias suggestions, and the retired-flag error.
- `docs/content/extending.md` — adds an "Inspecting and previewing"
subsection.

## Test plan

- [x] Full suite via `wt hook pre-merge --yes` (3271 tests, all passing)
- [x] `cargo insta test --accept` — all snapshots up to date
- [x] `cargo test --test integration
test_command_pages_and_skill_files_are_in_sync` — docs synced
- [x] Manual verification: `wt config alias show <name>` and `wt config
alias dry-run <name> [-- args...]` in a repo with mixed user/project
aliases

> _This was written by Claude Code on behalf of Maximilian_

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 16:00:01 -07:00
Maximilian Roos 1fd6eaba20 fix(hook): inject target symmetrically on switch/create/start (#2295)
`pre-switch` already injected `target` (and conditionally
`target_worktree_path`), but `post-switch`, `pre-start`, and
`post-start` only injected `base`. A user writing `{{ target }}` in
`post-start` expecting the documented "target = bare vars" semantics
would hit an undefined-var error at runtime — runtime and docs
disagreed.

This injects `target` (skipped for detached HEAD) and
`target_worktree_path` (always present on switch/create since the
worktree just got created) in `handle_switch.rs` post-switch and
`worktree/switch.rs` pre-start (both `Regular` and `ForkRef` arms). The
commit hook injection (one-worktree, `target` only) is left untouched —
integration target may not have a worktree, and the existing behavior
already matches the design.

After merging main, the scope-aware validation refactor (#2288) had
landed with `PreStart`/`PostStart` whitelisting only
`base`/`base_worktree_path`. Adds `target`/`target_worktree_path` to
that whitelist so validation accepts the vars the runtime now injects,
and updates the stale `PreSwitch | PostSwitch` comment (post-switch now
injects target too). The
`test_validate_template_scope_rejects_out_of_scope_vars` case that
checked `{{ target }}` rejection in `PreStart` swaps to `{{ base }}` in
`PreMerge` — still proves scope rejection since `base` remains
switch/start-only.

Docs: adds a commit row to the `base`/`target` table scoped to
merge/squash context (standalone `wt step commit` doesn't inject
`target`), and rewrites the "only in two-worktree hooks" sentence to
describe each conditional var accurately.

Tests: adds one post-start case (create path) and one post-switch case
(existing-worktree switch path) that assert `{{ target }}` resolves to
the destination branch. The `post_create_upstream_template` snapshot
picks up `target` / `target_worktree_path` in its "Available variables"
error message — expected side effect of making them always-injected on
the switch path.

Out of scope: `pr_number`/`mr_number`/`pr_url`/`mr_url` (undocumented
ForkRef-only vars, separate concern).

> _This was written by Claude Code on behalf of max-sixty_

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-18 14:41:54 -07:00
Maximilian Roos 8e3abf46db feat(alias): route --key=value via template refs, add -- escape, drop --var (#2287)
## Summary

Replace the alias-arg parser's "every `--KEY=VALUE` binds; bare `--KEY`
errors" grammar with template-driven routing. A token only binds when
the alias's pipeline references `{{ KEY }}`; otherwise it forwards into
`{{ args }}`. Adds `--` as a literal-forward escape and removes the
`--var KEY=VALUE` / `--var=KEY=VALUE` special cases.

The template already declares which variables it consumes — using that
as the routing signal removes both surprise binding (silent
`env=staging` for templates that never reference `env`) and the
unknown-flag error class. Every post-alias token now has a deterministic
destination.

## Grammar

Tokens after `wt <alias>` are walked left-to-right:

| Token shape | Routes to |
|---|---|
| `--KEY=VALUE` or `--KEY VALUE` where the template references `{{ KEY
}}` | Bound — `KEY` becomes the template value |
| `--KEY=VALUE` where the template doesn't reference `KEY` | Forwarded
literally to `{{ args }}` |
| `--KEY` followed by another `--…` (or end of args) | Forwarded
literally to `{{ args }}` |
| Bare positional | Forwarded to `{{ args }}` |
| Anything after `--` | Forwarded to `{{ args }}` regardless of shape |

`--dry-run` is the only post-alias built-in. `--yes`/`-y` is global only
(`wt -y <alias>`) per #2290 — post-alias `--yes` falls through the shape
rule and forwards as positional. Hyphens in the key canonicalize to
underscores before lookup, so `--my-var=value` binds to `{{ my_var }}`.

## Behavior changes

| Input | Before | After |
|---|---|---|
| `wt rm --force` (no `{{ force }}` ref) | Errored "Unknown flag" |
Forwards `--force` to `{{ args }}` |
| `wt deploy --env staging` | Errored "Unknown flag" | Binds
`env=staging` if referenced; else forwards both to `{{ args }}` |
| `wt deploy --env=staging` (no `{{ env }}` ref) | Silently bound
`env=staging` | Forwards `--env=staging` to `{{ args }}` |
| `wt foo --var x=1` | Bound `x=1` via the special `--var` arm | Same as
`--var=x=1`: binds `var="x=1"` if referenced, else forwards |
| `wt run -- --env=staging` | `--env=staging` consumed | `--env=staging`
forwarded literally |
| `wt show --branch=override` (with `{{ branch }}` ref) | Bound (worked,
undocumented) | Bound, now documented and tested |

Users who relied on `--var KEY=VALUE` should switch to `--KEY=VALUE`
directly.

## Navigating the diff

- `src/commands/alias.rs` — `AliasOptions::parse` rewritten as a
left-to-right walk against `referenced_vars: &BTreeSet<String>`.
`try_alias` and `step_alias` resolve the alias's `CommandConfig` first,
compute `referenced_vars`, then parse. `step_alias` now takes
`Vec<String>` (parses internally) because routing needs the resolved
alias.
- `src/config/expansion.rs` — new `referenced_vars_for_config` helper
unions `template.undeclared_variables(false)` across every command in a
pipeline.
- `src/main.rs` — call site updated for new `step_alias` signature.
- `tests/integration_tests/step_alias.rs` — six new integration tests
cover the grammar end-to-end (referenced bind, unreferenced forward,
`--` escape, multi-step binding, built-in overshadow, space-separated
bind). The `--var` tests are gone.
- `tests/integration_tests/approval_ui.rs` — updated
`test_post_alias_yes_does_not_skip_approval` (renamed from main's
`…_no_longer_supported`): under the new grammar `--yes` doesn't error,
it forwards as positional, but still doesn't skip approval.
- `docs/content/extending.md` (and auto-synced skill reference) — new
"How arguments are routed" section, `--` escape documented, built-in
overshadow caveat added.

## Test plan

- [x] `cargo run -- hook pre-merge --yes` (3255 tests, all pass)
- [x] Unit tests for parse grammar: `test_parse_built_in_flags`,
`test_parse_key_value_routing`, `test_parse_space_separated_routing`,
`test_parse_hyphen_canonicalization`,
`test_parse_literal_forward_escape`, `test_parse_mixed_pipeline`,
`test_parse_positionals` (covers post-alias `--yes`/`-y` forwarding),
`test_referenced_vars_for_config_unions_steps`
- [x] Integration tests for end-to-end routing
- [x] Doc sync test passes

> _This was written by Claude Code on behalf of Maximilian_

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-18 14:15:24 -07:00
Maximilian Roos 7ae28e1d16 docs: trim needless parenthetical qualifiers (#2289)
Parenthetical examples (e.g., `origin/HEAD`) read naturally, but
qualifiers and conditions belong in prose. This trims three cases:

- `Checks git config worktrunk.default-branch (single command)` → drop
the redundant `(single command)` (the command fragment already shows
it's a single command).
- `queries git ls-remote (100ms–2s)` → `queries git ls-remote —
typically 100ms–2s`.
- Statusline doc: `fetches from the network (often ~1–2 seconds), making
it suitable for async...` → em-dash clause so the timing detail doesn't
interrupt the main claim.

Auto-synced: `docs/content/config.md`, both
`skills/worktrunk/reference/*.md` copies, and the
`help_config_state_default_branch.snap` help snapshot.

> _This was written by Claude Code on behalf of Maximilian_

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-18 12:04:00 -07:00
Maximilian Roos 1a4eea3c03 feat(cli): promote --yes to a global flag (#2279)
## Summary

`-y, --yes` is now a top-level global clap flag. It moves off every
subcommand's clap args and lives once on `Cli`, so `wt -y <anything>`,
`wt <anything> --yes`, and `wt --yes <anything>` all skip any approval
or confirmation prompt for that invocation.

## Why

Follow-up to the top-level alias dispatch (#2266). The long-term plan is
to unify approval-bypass behavior — `-y` should be a true global rather
than duplicated across switch, remove, merge, commit, squash, prune, all
ten hook subcommands, shell install/uninstall, plugin install/uninstall,
and config update. A single canonical flag removes ~50 lines of
duplicated clap definitions and one source of drift.

## Call-site survey

Approval and confirmation prompt sources, as surfaced by `rg -l
'approve_|requires_approval|confirm_'`:

- `approve_hooks` / `approve_hooks_filtered` — reads `ctx.yes`, already
threaded via `CommandContext::new(..., yes)`. No change needed; the
global now feeds those call sites from `handle_*_command` in `main.rs`.
- `approve_command_batch` (merge, config `hook approvals add`) — takes
`yes: bool`. Receives the global.
- `approve_alias_commands` — takes `yes: bool`. Receives `global_yes ||
opts.yes` (see "Alias compat" below).
- `handle_configure_shell` / `handle_unconfigure_shell` /
`handle_config_update` / `handle_claude_install[_statusline]` /
`handle_claude_uninstall` / `handle_opencode_install` /
`handle_opencode_uninstall` — take `yes: bool` for confirmation-prompt
bypass. All receive the global.

## Alias compat

`AliasOptions` is hand-rolled (not clap) so it doesn't conflict with the
clap global. Its post-alias `--yes` parsing is intentionally preserved
here — `run_alias` does `let skip_approval = global_yes || opts.yes;` so
both `wt -y deploy` and `wt deploy --yes` work unchanged. Removing the
post-alias form is a separate cleanup, tracked by the user's long-term
simplification plan.

## Navigating the diff

- `src/cli/mod.rs` — new `yes: bool` on `Cli` with `global = true`,
`short = 'y'`, `help_heading = "Global Options"`, `display_order = 103`
(slots after `-v`). Removed the per-command `yes` field from
`SwitchArgs`, `RemoveArgs`, `MergeArgs`.
- `src/cli/step.rs` — removed `yes` from `CommitArgs`, `SquashArgs`, and
`StepCommand::Prune`. Also dropped `help_heading = "Automation"` from
the four step subcommands where the group was left with a single flag
(`commit`, `squash`, `for-each`, `prune`); those flags now render under
default Options instead of a single-item group.
- `src/cli/hook.rs` — removed `yes` from all ten hook subcommands
(`pre-switch`, `post-switch`, `pre-start`, `post-start`, `pre-commit`,
`post-commit`, `pre-merge`, `post-merge`, `pre-remove`, `post-remove`).
`--yes` stays in `KNOWN_HOOK_LONG_FLAGS` so the shorthand rewriter still
recognizes it as a real flag rather than a template variable.
- `src/cli/config.rs` — removed `yes` from
`ConfigShellCommand::Install/Uninstall`, `ConfigCommand::Update` (and
its now-redundant `conflicts_with = "yes"` on `--print`),
`ConfigPluginsOpencodeCommand::Install/Uninstall`,
`ConfigPluginsClaudeCommand::Install/Uninstall/InstallStatusline`.
- `src/main.rs` — `dispatch_command` takes `yes: bool`; each
`handle_*_command` accepts and threads it. `Cli` destructure adds `yes`.
- `src/commands/alias.rs` — `try_alias`, `step_alias`, `run_alias` take
`global_yes: bool`. `run_alias` OR's with `opts.yes` before calling
`approve_alias_commands`.
- `src/commands/custom.rs` — `handle_custom_command` accepts and passes
the global to `try_alias`.
- `docs/content/` + `skills/worktrunk/reference/` — auto-generated from
`--help-page`; the global appears under "Global Options" on every
subcommand.
- `tests/integration_tests/approval_ui.rs` — six new tests:
`test_global_yes_before_subcommand`, `test_global_yes_for_hook`,
`test_global_yes_for_alias`, `test_post_alias_yes_still_works`,
`test_global_yes_for_step_alias`,
`test_global_yes_on_command_without_approval`.

## Notes

- Does not remove `AliasOptions::yes` — deferred cleanup per the task
brief.
- Snapshot churn (~27 help files) is the expected fallout of adding a
global flag; each subcommand's `--help` now shows `-y, --yes` under
Global Options.

## Test plan

- [x] 3242 tests pass (`cargo run -- hook pre-merge --yes`)
- [x] Lints clean (clippy, cargo fmt, pre-commit)
- [x] Doc sync test passes after regeneration
- [x] New approval_ui tests cover before-subcommand, after-subcommand,
alias, post-alias, step-alias, and no-approval positions

> _This was written by Claude Code on behalf of Maximilian_

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
2026-04-18 11:08:41 -07:00
Martin Ziserman c70e77bf2d fix: reject underscore in vars keys (invalid git config variable name) (#2285) 2026-04-18 07:41:50 -07:00
Maximilian Roos 64e1b4efb9 feat(cli): move approvals subcommand from hook to config (#2282) 2026-04-18 00:46:27 -07:00
Maximilian Roos ae62b2fa35 release: land v0.39.0 commit on main (#2281)
The v0.39.0 tag and GitHub release were published, but the release
commit itself never landed on main. This PR is remedial: it cherry-picks
the tagged commit `c1fad73f3` onto `origin/main` so the release bump and
CHANGELOG entry become part of the default branch.

`c1fad73f3` is the canonical release — it's what the `v0.39.0` tag and
GitHub release point to. Cherry-pick applied cleanly on top of
`06073d5b6`; the resulting commit touches only `Cargo.toml`,
`Cargo.lock`, `CHANGELOG.md`,
`docs/static/.well-known/agent-skills/index.json`, and
`skills/worktrunk/SKILL.md`.

Note: local `main` currently has two stale duplicate "Release v0.39.0"
commits (`5958147b7`, `e8063d74a`) that were never pushed. Those will be
cleaned up separately — this PR is solely about landing the tagged
`c1fad73f3`.

> _This was written by Claude Code on behalf of Maximilian_
2026-04-17 23:58:11 -07:00