Skip to content

fix: contain termimad panic on ragged tables in PR comments - #3408

Merged
max-sixty merged 2 commits into
mainfrom
fix/issue-3407
Jul 10, 2026
Merged

fix: contain termimad panic on ragged tables in PR comments#3408
max-sixty merged 2 commits into
mainfrom
fix/issue-3407

Conversation

@worktrunk-bot

Copy link
Copy Markdown
Collaborator

Problem

Running wt switch (the interactive picker) crashes with:

thread '<unnamed>' panicked at termimad-0.34.1/src/tbl.rs:176:30:
index out of bounds: the len is 7 but the index is 7
Rayon: detected unexpected panic; aborting

The panic originates in the termimad crate (latest version, 0.34.1 — no upstream fix available), reached via the picker's PR-comments preview pane: picker::prs::render_comment_blocksmd_help::render_table_with_termimadMadSkin::text.

Root cause (upstream bug): termimad's column fitter, Table::fix_columns, panics on a ragged table — one where some row has more cells than the header — when it's rendered at a width too narrow to fit the widest row. In that case the fitter's TblFit::new fails and it takes an error branch that skips padding the short rows to the column count, then the alignment loop indexes past them (cells[ic] for ic in 0..nbcols). Equal-column tables never hit this at any width; only ragged ones do.

PR/MR comment markdown is untrusted and can contain such a table. Because the comments pane renders on a rayon worker, the escaped panic aborts the entire process rather than just failing one render.

Solution

Contain the upstream panic at the single termimad call site (render_table_with_termimad in src/md_help.rs) with std::panic::catch_unwind, falling back to the plain preprocessed table lines so the pane still shows the table's text. This keeps the panic from escaping to the rayon worker and taking down wt switch, and guards the whole class of termimad table panics rather than one specific input.

Testing

Added test_render_table_ragged_narrow_does_not_panic in src/md_help.rs, which renders a ragged table (| Key | Value | header over a six-column data row) at a narrow width. Verified it panics at tbl.rs:176:30 without the fix (same location as the report) and passes with it. Full md_help suite (26 tests) and cargo clippy are green.


Closes #3407 — automated triage

termimad 0.34.1 panics with an out-of-bounds index (src/tbl.rs:176) when a
ragged markdown table — a row with more cells than the header — is rendered at
a width too narrow to fit the widest row. Its column fitter takes an error path
that skips padding the short rows, then indexes past them.

PR-comment markdown is untrusted and can contain such a table. The `wt switch`
picker renders comment bodies through `render_table_with_termimad` on a rayon
worker, so the escaped panic aborted the whole process ("Rayon: detected
unexpected panic; aborting").

Contain the panic with catch_unwind and fall back to the plain preprocessed
lines so the pane still shows the table's text instead of crashing.

Closes #3407

Co-Authored-By: Claude <noreply@anthropic.com>

@worktrunk-bot worktrunk-bot left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Self-review of the termimad panic containment. The core fix is sound — catch_unwind around the single termimad call is a clean, minimal way to stop the rayon-worker abort from taking down wt switch, AssertUnwindSafe is justified (the closure only reads skin/markdown/width and nothing inconsistent is observed afterward), and there's no panic = "abort" profile so the unwind is actually catchable. One design observation worth a human's eye before merge, plus a smaller test note.

The panic message still reaches stderr and can smear the picker frame. catch_unwind swallows the unwind, but Rust's default panic hook runs first and writes thread panicked at termimad-0.34.1/src/tbl.rs:176:30 ... to stderr. This module (src/commands/picker/mod.rs) deliberately stashes its own eprintln output and drains it to stderr only "once the picker releases the terminal" (see the doc comments around mod.rs lines ~146 and ~1162) precisely because writing to stderr while skim owns the terminal corrupts the display. So on the rare trigger this converts a hard abort into a one-time garbled frame (recovers on the next repaint) rather than a fully clean fallback — still a clear improvement, but not the silent degrade the comment implies. A truly clean fallback would need to suppress that hook write (a process-wide panic::set_hook installed at startup, since a scoped set_hook is racy across concurrent preview workers). Reasonable to defer, but worth a conscious call rather than leaving it implicit.

The regression test doesn't distinguish the fallback from a successful render. test_render_table_ragged_narrow_does_not_panic asserts only that plain contains "alpha" and "zeta" — but a successful termimad render of the same table also contains those cell texts, so the assertion passes on either path. It currently exercises the fallback (you verified the panic fires without the fix), but if a future termimad release fixes the ragged-table bug or the width-20 reproducer stops tripping the fitter arithmetic, the test stays green while silently no longer covering catch_unwind. Asserting on something only the fallback produces (e.g. the raw | pipe delimiters from processed.join, which a real termimad render replaces with box-drawing borders) would keep it honest.

Not flagging the shared-leaf vs. rayon-boundary placement: render_data_table builds equal-column tables by construction, so the trusted --help/config state callers can't be ragged and won't hit this path — the broadened containment is practically harmless.

@max-sixty

Copy link
Copy Markdown
Owner

also please draft an issue for me to put into termiad; check whether one still exists

The regression test asserted only that the output contains the cell text
("alpha"/"zeta"), which a successful termimad render of the same table
also contains — so it passed on either path. If a future termimad release
fixes the ragged-table panic (or the width-20 reproducer stops tripping
the fitter), the test would stay green while silently no longer covering
`catch_unwind`. Add an assertion on the literal `|` delimiters, which only
the plain-text fallback preserves (a real render replaces them with
box-drawing `│`), so the test keeps exercising the containment path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@worktrunk-bot

Copy link
Copy Markdown
Collaborator Author

Upstream termimad issue

Existing issue check: I searched Canop/termimad (open + closed) for panic, index out of bounds, table, tbl, fix_columns, ragged — no existing issue covers this. The three closed panic reports (#7, #22, #23) are all broken-pipe panics in print_text, unrelated to the table fitter. So this is new — draft below, ready to paste.

I confirmed the reproducer against a standalone termimad = "=0.34.1" binary (no worktrunk code): it panics at exactly tbl.rs:176:30 — same location as the crash report. I also mapped the width band: the ragged table panics at widths 16/20/24, renders fine at 8/12 and at 30+, and the equal-column version never panics at any width. That's all folded into the draft.

Draft issue for Canop/termimad (raw markdown, copy-paste)
### Panic: index out of bounds in `Table::fix_columns` on a ragged table at narrow width

`MadSkin::text` (and any render path through `Table::fix_columns`) panics with an out-of-bounds index when a **ragged** table — one where a row has more cells than the header — is rendered at a width too narrow to fit the widest row.

#### Reproducer

```rust
// termimad = "=0.34.1"
use termimad::MadSkin;

fn main() {
    let skin = MadSkin::default();
    // Header has 2 columns; the data row has 6.
    let md = "| Key | Value |\n\
              | --- | --- |\n\
              | alpha | beta | gamma | delta | epsilon | zeta |\n";
    // Panics at tbl.rs:176:30
    let _ = skin.text(md, Some(20)).to_string();
}
```

#### Observed

```
thread 'main' panicked at termimad-0.34.1/src/tbl.rs:176:30:
index out of bounds: the len is 2 but the index is 2
```

#### When it happens

Only for **ragged** tables, and only in a band of widths that are too narrow to fit the widest row but not tiny:

| table | width | result |
|-------|-------|--------|
| ragged (2-col header, 6-col row) | 8, 12 | ok |
| ragged | 16, 20, 24 | **panic** |
| ragged | 30, 40, 60, 200 | ok |
| equal-column (2-col header, 2-col row) | 4, 20, 40 | ok |

#### Mechanism

In [`Table::fix_columns`](https://github.com/Canop/termimad/blob/bc20f734ee09a900960da6c90170fb6e7b5a861b/src/tbl.rs#L94), `nbcols` is the table's column count (the widest row for a ragged table). When [`TblFit::new(nbcols, width)` returns `Err`](https://github.com/Canop/termimad/blob/bc20f734ee09a900960da6c90170fb6e7b5a861b/src/tbl.rs#L113-L118) — the "not enough width, remove columns" path — `nbcols` is recomputed to `(width - 1) / 4`, but that branch **skips** the per-row cell-padding loop that the `Ok` branch runs. Padding is then left to the resize loop, where a cell wider than its column is wrapped and its overflow inserted as a new continuation row; that inserted row is built from `cells_to_add`, which only has entries for the row's original cells, so it can end up with fewer than `nbcols` cells. The final alignment loop then does [`cells[ic]` for `ic in 0..nbcols`](https://github.com/Canop/termimad/blob/bc20f734ee09a900960da6c90170fb6e7b5a861b/src/tbl.rs#L175-L176) on every row and indexes past the short one — the panic.

#### Suggested fix

Guarantee every row (including inserted continuation rows) has exactly `nbcols` cells before the alignment loop — e.g. pad short rows in the `Err`/`cols_removed` branch the same way the `Ok` branch does, or bound-check the alignment loop with `cells.get(ic)`.

#### Environment

- termimad 0.34.1
- Reproduces via `MadSkin::text(markdown, Some(width))`; the input is untrusted markdown (a table pasted into a PR comment), so it's reachable from user data.

Two notes on the self-review, for the record:

  • Test point (addressed): I pushed 1dbaf80 strengthening test_render_table_ragged_narrow_does_not_panic. It now also asserts the output contains a literal |, which only the plain-text fallback preserves — a successful termimad render replaces the delimiters with box-drawing . The old cell-text-only assertion passed on either path, so it would have gone green (and stopped covering catch_unwind) if termimad ever fixed the bug upstream.
  • stderr-smear point (deferred): left as-is — Rust's default panic hook still writes the panic message to stderr before catch_unwind swallows the unwind, so on the rare trigger the picker frame can smear once and recover on the next repaint, rather than degrading fully silently. A clean fix needs a process-wide panic::set_hook at startup; flagging for your call rather than doing it here.

@max-sixty
max-sixty merged commit 26f6658 into main Jul 10, 2026
37 checks passed
@max-sixty
max-sixty deleted the fix/issue-3407 branch July 10, 2026 10:37
@max-sixty

Copy link
Copy Markdown
Owner

@worktrunk-bot does this still happen with termimad 0.35?

@worktrunk-bot

Copy link
Copy Markdown
Collaborator Author

Yes — still reproduces in termimad 0.35.0 (published earlier today). Same bug, unfixed upstream.

I ran the original reproducer against a clean termimad = "0.35.0" scratch crate:

width   8: ok
width  12: ok
width  16: PANIC
width  20: PANIC
width  24: PANIC
width  30: ok
width  40: ok
width  60: ok
width 200: ok

Identical width band (16/20/24) and the same panic site — Table::fix_columns:

thread 'main' panicked at termimad-0.35.0/src/tbl.rs:176:30:
index out of bounds: the len is 2 but the index is 2

So the catch_unwind guard added here is still warranted; there's no upstream fix to depend on yet. I'd hold off on bumping to 0.35.0 for this reason — it buys nothing here. (If you want the bump for its other changes I can open a separate PR; the strengthened test_render_table_ragged_narrow_does_not_panic would keep covering the guard either way.)

@max-sixty max-sixty mentioned this pull request Jul 10, 2026
max-sixty added a commit that referenced this pull request Jul 10, 2026
Release v0.67.0: version bump and changelog.

Highlights since v0.66.0: experimental `wt remove --reap` (#3396), `wt
switch -x` opening the picker (#3394), the termimad panic containment in
the picker's PR-comments preview (#3408), the `wt config show` SIGTTOU
fix (#3327), the `GIT_*` discovery-var scrub for `wt step for-each` and
the `--execute` fallback (#3400), and the `wt list` prompt-reserve fix
(#3409).

Seven commits landed on `main` after the initial cut; `origin/main` is
merged back in and the two user-facing ones (#3394, #3411) plus the BY
CONTEXT profile table (#3403) are folded into the changelog.

Pre-release validation: local pre-merge gate green on the merged tree
(4389 tests); nightly cross-platform suite green on the initial cut
([run
29086956095](https://github.com/max-sixty/worktrunk/actions/runs/29086956095)),
with the seven post-cut commits each validated by their own PR CI;
`cargo semver-checks` reports no breaking changes; data-loss surface
review of the cumulative diff (including the drift commits) found one
new destructive capability (`--reap`, explicit opt-in, adjudicated
acceptable).

> _This was written by Claude Code on behalf of max_
@worktrunk-bot

Copy link
Copy Markdown
Collaborator Author

Surfacing a cross-repo question here, since I can't act on it in-thread.

Over in Canop/termimad#77 — the upstream issue for this panic — @Canop asked whether I'd open the fix PR or he should. I'm not able to open PRs (or comment) in repos outside this one, so I can't respond there or push the fix upstream myself. Two ways forward:

  • Hand off the patch (recommended). I traced the bug to a one-line invariant gap; it's ready for you or @Canop to apply directly — details below.
  • Relax the cross-repo rule. If you'd like me to open upstream PRs like this going forward, I can propose a running-tend change permitting it for agent-adjacent dependencies. That's a broad policy loosening, though, so I'd rather you decide it explicitly than have me infer it.
Verified fix for termimad tbl.rs (source-traced against v0.35.0, not runtime-tested)

The panic is in Table::fix_columns. When a table is too narrow to fit its widest row, the fitter takes the Err "remove columns" branch and skips the per-row cell-padding loop the Ok branch runs. The resize loop compensates by padding short rows through the cells.len() <= ic continue branch — but that branch pushes to cells without a matching push to cells_to_add, unlike the fall-through one line below. So cells_to_add ends up with fewer than nbcols entries; the continuation row built from it gets fewer than nbcols cells; and the final alignment loop's cells[ic] then indexes past it → index out of bounds: the len is N but the index is N.

Minimal fix — restore the invariant the Ok branch already maintains (every row and every cells_to_add slot has nbcols entries) by pushing an empty slot in the continue branch:

for ic in 0..nbcols {
    if cells.len() <= ic {
        cells.push(FmtComposite::new());
        cells_to_add.push(Vec::new()); // keep cells_to_add aligned with nbcols
        continue;
    }
    cells_to_add.push(Vec::new());
    // ...

The continues are always at the tail (cells are padded in index order), so cells_to_add stays index-aligned with ic. Equivalent alternatives if defense-in-depth is preferred: build the continuation row over 0..nbcols using cells_to_add.get_mut(ic), or bound the alignment loop with cells.get_mut(ic).

Verification level: traced against 1a22ecd (v0.35.0) and matched to the exact panic site (tbl.rs:176) and message; I did not compile a patched build. The reproducer already in the issue confirms the panic empirically across the narrow-width band.

@Canop

Canop commented Jul 11, 2026

Copy link
Copy Markdown

Ok @worktrunk-bot I'll check your fix and do the change in Termimad

@worktrunk-bot

Copy link
Copy Markdown
Collaborator Author

Thanks @Canop, much appreciated — and thanks for maintaining termimad. The reproducer in #77 covers the whole narrow-width band (16/20/24) plus the ok cases on either side, so it should make a decent regression test if useful.

No rush on our end — we've got the catch_unwind guard containing it locally, so we're unblocked. Once a fixed release is out I'll drop the workaround and bump. Happy to help verify a patched build against the repro if that's handy.

@Canop

Canop commented Jul 11, 2026

Copy link
Copy Markdown

version 0.35.1 has been published and has the fix

@worktrunk-bot

Copy link
Copy Markdown
Collaborator Author

Thanks @Canop — fast turnaround, much appreciated. Verified 0.35.1 renders the reproducer clean across the whole narrow-width band (16/20/24 and beyond), so I've opened #3422 to bump to it and drop the catch_unwind workaround this PR added.

Skogix added a commit to skogai/worktrunk that referenced this pull request Jul 11, 2026
commit 550d6641b6e1888b8a0712b637308ee00639e3ff
Author: Maximilian Roos <5635139+max-sixty@users.noreply.github.com>
Date:   Sat Jul 11 03:04:10 2026 -0700

    skills(running-tend): drop case-specific example from root-cause section (#3421)

    Follow-up to #3414: drops the example paragraph citing PR #3390 / issue
    #3389 from the "Weigh the root-cause fix" section. Skills state the
    recurrence pattern generically; per-incident references require
    institutional memory the reader lacks and age into noise. The rule
    paragraph stands alone; the case remains documented in #3414 and the
    #3390 discussion.

    > _This was written by Claude Code on behalf of max_

    Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

commit 69d6afb1fe7cd441278c02e57941208c3425b3f8
Author: Worktrunk Bot <w@worktrunk.dev>
Date:   Fri Jul 10 20:37:21 2026 -0700

    fix: restore Claude plugin hooks to conventional hooks/hooks.json (#3418)

    ## Problem

    Since [#3382](https://github.com/max-sixty/worktrunk/pull/3382)
    ("Claude-scope the shared hooks payload"), the Claude plugin's hooks
    stopped loading in Claude Code. Skills and commands still load, so the
    plugin looks healthy — but `/hooks` shows none of worktrunk's handlers
    and the 🤖/💬 activity markers never update. It fails silently.

    Root cause (confirmed by the reporter on Claude Code 2.1.207): Claude
    Code discovers plugin hooks by **convention** at `hooks/hooks.json`. It
    does **not** honor `plugin.json`'s string-path `"hooks":
    "./hooks/claude-hooks.json"` override for plugin loads. The #3382 rename
    moved the file off the only path Claude's loader actually reads.

    The rename was intended to stop Codex's convention discovery from
    surfacing Claude's events
    ([#3362](https://github.com/max-sixty/worktrunk/issues/3362)). But the
    Codex manifest already defined its hooks **inline** (`Some(Inline)`
    branch of `resolve_manifest_hooks`), which overrides Codex's convention
    discovery — and that inline definition **predates** the rename (verified
    against `2677f05^`). So the rename was always redundant for the Codex
    scoping, while it broke Claude's discovery.

    ## Solution

    Restore the file to the conventional
    `plugins/worktrunk/hooks/hooks.json` and point `plugin.json`'s `hooks`
    back at it. `plugin.json` reverts byte-for-byte to its pre-#3382 state.

    This does not resurface the #3362 collision: the Codex manifest's inline
    `hooks` object keeps Codex off the shared `hooks/hooks.json` regardless
    of the filename. The two toolchains now coexist on one file — Claude
    discovers it, Codex ignores it via the inline override. The
    `test_plugin_layout_is_consolidated` assertions covering the Codex
    inline manifest are unchanged and still pass.

    Also updates `plugins/worktrunk/CLAUDE.md` (layout diagram + Known
    Limitations) and drops the now-reverted #3382 CHANGELOG entry, replacing
    it with a Fixed entry (both were in the unreleased 0.67.0, so the net
    change is nil).

    ## Testing

    Reproduction is captured in `test_plugin_layout_is_consolidated`
    (`tests/integration_tests/config_show.rs`), whose invariant was inverted
    from "the Claude file must be Claude-scoped" to "the Claude file must
    sit at the conventional `hooks/hooks.json` that Claude Code's loader
    discovers." Against pre-fix production it fails (`left:
    "./hooks/claude-hooks.json"`); after the rename it passes.

    - `test_plugin_layout_is_consolidated` ✅
    - `test_claude_hook_commands_parse_in_all_shells` ✅ (reads the renamed
    file; shell-integration-tests)
    - Full `integration_tests::config_show` module: 136 passed ✅
    - `test_docs_are_in_sync` ✅

    **Not verified end-to-end from CI** (per running-tend "Don't ship fixes
    you can't verify"): the bot cannot drive a live Claude Code or Codex
    session to observe the hooks firing. The fix rests on the reporter's
    confirmed symptom + repro (symlinking `hooks/hooks.json` →
    `claude-hooks.json` immediately restores the hooks) and on Claude Code's
    [documented convention
    path](https://code.claude.com/docs/en/plugins-reference.md)
    (`hooks/hooks.json` in plugin root). The Codex-stays-clean claim rests
    on the existing code analysis in CLAUDE.md and the inline manifest
    predating the rename, not a live Codex run.

    ---
    Closes #3417 — automated triage

    ---------

    Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>

commit f2508771d28aa98140c6c56add8769243d5c0a3d
Author: Worktrunk Bot <w@worktrunk.dev>
Date:   Fri Jul 10 16:26:20 2026 -0700

    skills(running-tend): weigh root-cause fix over lossy config workaround (#3414)

    Adds a triage rule to `running-tend` capturing a maintainer-flagged bad
    case from the daily review-runs analysis.

    ## The bad case

    In [#3390](https://github.com/max-sixty/worktrunk/pull/3390) (triage of
    [#3389](https://github.com/max-sixty/worktrunk/issues/3389)), triage of
    the `⚑` `branch_worktree_mismatch` flag shipped a **docs-only** PR
    recommending users reconfigure `worktree-path = "{{ branch | basename
    }}"`. On [#3390 the maintainer pushed
    back](https://github.com/max-sixty/worktrunk/pull/3390#issuecomment-4928586504)
    — "why do we drop the prefix?" — then [explicitly asked to log it as a
    bad
    case](https://github.com/max-sixty/worktrunk/pull/3390#issuecomment-4928926873)
    and closed the PR.

    Two problems the rule targets:

    - **Lossy workaround presented as *the* fix.** `basename` collapses
    `alice/foo` and `bob/foo` to `foo`, colliding on one directory, whereas
    the default `{{ branch | sanitize }}` keeps them distinct (`alice-foo` /
    `bob-foo`). The collision downside only surfaced after the maintainer
    challenged it.
    - **Jumped to a docs workaround over the root-cause code fix.** The
    proportionate fix was teaching the path-match check to tolerate a
    dropped namespace prefix, not asking every affected user to adopt a
    lossy template. The "docs-only, no risk" framing masked a bad
    recommendation — low code risk ≠ good guidance.

    ## The rule

    A new `### Weigh the root-cause fix before shipping a config/docs
    workaround` subsection under Issue Triage, placed right after
    "Suggesting Aliases for Niche Feature Requests" (which encourages
    config-based deflection) as a tempering counterpoint: check whether a
    configurable workaround is lossy/foot-gunny, weigh a proportionate
    root-cause code fix first, and surface any recommended config's
    downsides up front.

    ## Gate assessment

    - **Gate 1 (confidence)**: Critical — directly flagged by @max-sixty
    ("log this as a bad case"). 1 occurrence is sufficient at Critical.
    - **Gate 2 (magnitude)**: Targeted one-subsection addition; evidence bar
    met by the Critical maintainer flag.

    Already recorded in the [review-runs-tracking evidence
    log](https://github.com/max-sixty/worktrunk/issues/3349#issuecomment-4923383823).
    Kept as a separate atomic PR from the open cargo-PATH skill note
    ([#3361](https://github.com/max-sixty/worktrunk/pull/3361)) — different
    concern, non-overlapping section.

    <sub>Run
    [29081037464](https://github.com/max-sixty/worktrunk/actions/runs/29081037464)
    · daily review-runs</sub>

    Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
    Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

commit abf9155546c84e65a09118f4b82e7a7c545a8d66
Author: Maximilian Roos <5635139+max-sixty@users.noreply.github.com>
Date:   Fri Jul 10 06:10:26 2026 -0700

    Release v0.67.0 (#3416)

    Release v0.67.0: version bump and changelog.

    Highlights since v0.66.0: experimental `wt remove --reap` (#3396), `wt
    switch -x` opening the picker (#3394), the termimad panic containment in
    the picker's PR-comments preview (#3408), the `wt config show` SIGTTOU
    fix (#3327), the `GIT_*` discovery-var scrub for `wt step for-each` and
    the `--execute` fallback (#3400), and the `wt list` prompt-reserve fix
    (#3409).

    Seven commits landed on `main` after the initial cut; `origin/main` is
    merged back in and the two user-facing ones (#3394, #3411) plus the BY
    CONTEXT profile table (#3403) are folded into the changelog.

    Pre-release validation: local pre-merge gate green on the merged tree
    (4389 tests); nightly cross-platform suite green on the initial cut
    ([run
    29086956095](https://github.com/max-sixty/worktrunk/actions/runs/29086956095)),
    with the seven post-cut commits each validated by their own PR CI;
    `cargo semver-checks` reports no breaking changes; data-loss surface
    review of the cumulative diff (including the drift commits) found one
    new destructive capability (`--reap`, explicit opt-in, adjudicated
    acceptable).

    > _This was written by Claude Code on behalf of max_

commit bd504f09982f4bababfcb33d649490312fad6819
Author: Worktrunk Bot <w@worktrunk.dev>
Date:   Fri Jul 10 04:30:14 2026 -0700

    feat(switch): run --execute against the picked worktree (#3394)

commit 4f6f6d8dc28efce94f97d06a6767631e2b5dce14
Author: Worktrunk Bot <w@worktrunk.dev>
Date:   Fri Jul 10 04:27:29 2026 -0700

    fix(test): tolerate the prune race in picker alt-x branch assertions (#3412)

commit dac0f80608b909b6b0d254b793e046267d193e60
Author: Maximilian Roos <5635139+max-sixty@users.noreply.github.com>
Date:   Fri Jul 10 03:56:40 2026 -0700

    feat(config): wt config update pins [list] json-schema while unset (#3411)

    Wires the `[list] json-schema` nag into the standard config-update
    machinery, so the warning comes with the standard one-command fix.

    `wt config update` now pins `json-schema = 1` (the behavior-preserving
    choice) when the key is unset, via a new `PendingDefault` variant in
    `DEPRECATION_RULES`: applied on the update pass only — the load path
    must not pin, or an in-memory `Some(1)` would silence the nag — and
    scoped to user config through a `ConfigFileKind` enum threaded through
    detection in place of the old string labels. Two guards keep the pin
    honest: it stays inert when the system config layer already defines the
    key (a user-file pin would override a system-level `= 2` and flip
    resolved output), and the nag's hint offers `wt config update` only when
    the same detection update runs would actually write the pin — a missing,
    unreadable, or malformed user config falls back to naming the manual
    setting.

    The detection-equals-migration invariant holds with the warning
    relocated: the pin's warning fires at the JSON-emitting surface
    (`resolve_json_schema`) exactly when update would change the file, while
    config load stays quiet (`DeprecationKind::is_pending_default` filters
    it, and pin-only configs skip the warning-dedup machinery entirely), so
    `wt switch` users never see it. `wt config show` renders the pending
    pin's diff — including for empty config files — but keeps its TOML dump:
    a pin is additive, unlike a deprecation diff that supersedes the dump.

    This departs from the plan reviewed in `design/list-json-v2.md` (#3357),
    which deferred the `wt config update` integration to the default flip.
    Deliberate tradeoff: users who run update during the window land pinned
    on schema 1 and will see the `= 1` deprecation round after the flip; in
    exchange, the warning ships with its fixer.

    **Testing:** unit tests pin the rule's iff (unset ⟺ update changes the
    file), kind scoping (System/Project inert, load pass inert), the
    PendingDefault-rule/kind coupling, and placement in existing or implicit
    `[list]` sections; integration tests cover both hint variants, the
    update flow end-to-end (pin applied, second run clean), `--print`, and
    the system-config deferral. The invariant battery runs with an explicit
    pin appended so each case exercises only its own rule. Full pre-merge
    gate green (4386 tests) plus `--features shell-integration-tests`
    clippy.

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

    > _This was written by Claude Code on behalf of max_

    ---------

    Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

commit 18d2bef84b989b4186d8bb1b2a9398d62d22a00e
Author: Maximilian Roos <5635139+max-sixty@users.noreply.github.com>
Date:   Fri Jul 10 03:44:18 2026 -0700

    refactor(perf): canonicalize the benchmark / wt-perf system (#3403)

    Implements the design proposal from this branch's first commit
    (b22aced85, `design/simplify-bench-perf.md` — reviewable there; dropped
    from the merge candidate per the `design/` convention). The perf
    tooling's emission side was already canonical; this consolidates the
    consumption side around one split: capture/harness work in `wt-perf`,
    analysis in `worktrunk::trace` with `wt config state logs profile` as
    its one CLI surface.

    The seven design items:

    1. **`wt-perf cache-check` deleted** — its output was the `cache` field
    of `logs profile --format=json`. The tend statusline recipe now uses
    `logs profile` (its old pipe also fed human-format stderr into a JSON
    parser, so it's now correct as well as canonical).
    2. **Timeline renderer moved into `worktrunk::trace`**
    (`src/trace/timeline.rs`), sharing
    `command_label`/`render_table`/`fmt_dur` with the profile so the two
    views can't drift (they already had); drops wt-perf's `tabwriter` and
    `insta` deps. Durations now render in the profile's fixed-point format.
    3. **`Profile.by_context`** (BY CONTEXT table + JSON array):
    per-worktree subprocess totals, the one analysis that previously
    required trace_processor SQL. The SQL sections in `benches/CLAUDE.md`
    and the `trace` module docs are replaced by a `logs profile` pointer;
    Perfetto stays for visual critical-path work.
    4. **One warm/cold bench runner** (`wt_perf::bench_wt`) owns the
    `BatchSize::PerIteration` rationale once, replacing five inline copies.
    All runs now assert child exit status (`benches/list.rs` previously
    ignored it).
    5. **Bench matrix pruned**: `real_repo` keeps only the 8-worktree
    variants (scaling shape stays on synthetic `worktree_scaling`),
    `cow_copy` deleted (benchmarked a hand-written serial copy against
    production rayon — a settled choice), `remove_e2e/first_output` deleted
    (duplicate of `first_output/remove`). Ends three gist series with this
    PR as the single discontinuity point; saves roughly 15–20 min of the
    ~80-min daily bench run.
    6. **wt-perf CLI tests moved in-package** (native
    `CARGO_BIN_EXE_wt-perf`): the dummy `builds.rs` is gone and the nextest
    setup script builds only `mock-stub`. Note `cargo test --test
    integration` no longer runs these four tests; full-workspace runs still
    do.
    7. **`parse_config` returns a `SetupConfig` enum** covering `mixed-W-B`;
    the two fixture builders share `init_bench_repo`.

    **Merge with main (#3401):** the new prune benches and fixtures landed
    mid-flight in exactly these files; the resolution routes them through
    the consolidated API — `prune-M-U` is a `SetupConfig::Prune` variant,
    the pair parsing is one shared `parse_pair`, and `prune-real` keeps its
    own cache-managed path (no `--path`, self-repairing). `benches/prune.rs`
    keeps its custom `iter_batched` arms deliberately: every variant asserts
    the candidate count from stdout, which `bench_wt`'s shape doesn't
    express — the exact carve-out the cache-handling docs describe.

    Two review passes (correctness + subtraction) then folded in:
    `CacheReport` trimmed to its same-context fields (the dropped six
    duplicated `Profile` fields or counted cross-context noise the text
    report never showed), `render_table` pads by display width (replacing an
    ASCII-only invariant the new BY CONTEXT table violated), the timeline's
    instant-event row gained test coverage, and Chrome args dropped the
    redundant `duration_ms`.

    **Decisions surfaced, not taken** (from the review passes): (a) `wt-perf
    setup` could become `--persist`-only, dropping the interactive
    Enter-to-cleanup path — a UX preference; (b) whether the daily benchmark
    cron's gist time series justifies its remaining ~60 min/day is a
    cost/value call. Also deferred: measuring whether `skeleton/cold` adds
    signal over `skeleton/warm` before cutting it.

    Gate green throughout (4374 tests); `clippy --features
    shell-integration-tests` clean; `setup`/`timeline` (warm, cold,
    chrome)/`logs profile` (text + JSON)/`trace` all driven end-to-end on a
    scratch `mixed-4-8` repo.

    > _This was written by Claude Code on behalf of max_

    ---------

    Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

commit fbf380b4471a580256f8da251de0aec9309828e3
Author: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Date:   Fri Jul 10 03:43:43 2026 -0700

    chore: bump taiki-e/install-action from 2.82.11 to 2.83.0 (#3405)

    Bumps
    [taiki-e/install-action](https://github.com/taiki-e/install-action) from
    2.82.11 to 2.83.0.
    <details>
    <summary>Release notes</summary>
    <p><em>Sourced from <a
    href="https://github.com/taiki-e/install-action/releases">taiki-e/install-action's
    releases</a>.</em></p>
    <blockquote>
    <h2>2.83.0</h2>
    <ul>
    <li>
    <p>Support <code>cargo-about</code>. (<a
    href="https://github.com/taiki-e/install-action/pull/1924">#1924</a>,
    thanks <a
    href="https://github.com/ruffsl"><code>@​ruffsl</code></a>)</p>
    </li>
    <li>
    <p>Update <code>uv@latest</code> to 0.11.28.</p>
    </li>
    <li>
    <p>Update <code>martin@latest</code> to 1.12.0.</p>
    </li>
    <li>
    <p>Update <code>kingfisher@latest</code> to 1.106.0.</p>
    </li>
    <li>
    <p>Update <code>biome@latest</code> to 2.5.3.</p>
    </li>
    </ul>
    </blockquote>
    </details>
    <details>
    <summary>Changelog</summary>
    <p><em>Sourced from <a
    href="https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md">taiki-e/install-action's
    changelog</a>.</em></p>
    <blockquote>
    <h2>[2.83.0] - 2026-07-09</h2>
    <ul>
    <li>
    <p>Support <code>cargo-about</code>. (<a
    href="https://github.com/taiki-e/install-action/pull/1924">#1924</a>,
    thanks <a
    href="https://github.com/ruffsl"><code>@​ruffsl</code></a>)</p>
    </li>
    <li>
    <p>Update <code>uv@latest</code> to 0.11.28.</p>
    </li>
    <li>
    <p>Update <code>martin@latest</code> to 1.12.0.</p>
    </li>
    <li>
    <p>Update <code>kingfisher@latest</code> to 1.106.0.</p>
    </li>
    <li>
    <p>Update <code>biome@latest</code> to 2.5.3.</p>
    </li>
    </ul>
    </blockquote>
    </details>
    <details>
    <summary>Commits</summary>
    <ul>
    <li><a
    href="https://github.com/taiki-e/install-action/commit/c7eb1735f09259a5035e8e5d44b1406b1cddc0fb"><code>c7eb173</code></a>
    Release 2.83.0</li>
    <li><a
    href="https://github.com/taiki-e/install-action/commit/84d4171800adc3ccfe039969324217bde4ca0f25"><code>84d4171</code></a>
    Update changelog</li>
    <li><a
    href="https://github.com/taiki-e/install-action/commit/21e5d859bea44c4a9c990a48a63fed2203e01d68"><code>21e5d85</code></a>
    Support cargo-about (<a
    href="https://github.com/taiki-e/install-action/issues/1924">#1924</a>)</li>
    <li><a
    href="https://github.com/taiki-e/install-action/commit/786ded99bea7500a913f1f19b18bfcfdf782fb97"><code>786ded9</code></a>
    Update <code>uv@latest</code> to 0.11.28</li>
    <li><a
    href="https://github.com/taiki-e/install-action/commit/1c1bbcf779fa11c2ef8cf0bda69bba43465230e6"><code>1c1bbcf</code></a>
    Update rclone manifest</li>
    <li><a
    href="https://github.com/taiki-e/install-action/commit/4e5eb09101d68b0b5f52740d1abc011ae003ac6f"><code>4e5eb09</code></a>
    Update <code>martin@latest</code> to 1.12.0</li>
    <li><a
    href="https://github.com/taiki-e/install-action/commit/dc84fc5f1a7ad878e27656eb3ed173e393c62867"><code>dc84fc5</code></a>
    Update <code>kingfisher@latest</code> to 1.106.0</li>
    <li><a
    href="https://github.com/taiki-e/install-action/commit/cf10e546be4ee50afcfd94a851bcd9eb64cba30d"><code>cf10e54</code></a>
    Update <code>biome@latest</code> to 2.5.3</li>
    <li>See full diff in <a
    href="https://github.com/taiki-e/install-action/compare/v2.82.11...v2.83.0">compare
    view</a></li>
    </ul>
    </details>
    <br />

    [![Dependabot compatibility
    score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=taiki-e/install-action&package-manager=github_actions&previous-version=2.82.11&new-version=2.83.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

    Dependabot will resolve any conflicts with this PR as long as you don't
    alter it yourself. You can also trigger a rebase manually by commenting
    `@dependabot rebase`.

    [//]: # (dependabot-automerge-start)
    [//]: # (dependabot-automerge-end)

    ---

    <details>
    <summary>Dependabot commands and options</summary>
    <br />

    You can trigger Dependabot actions by commenting on this PR:
    - `@dependabot rebase` will rebase this PR
    - `@dependabot recreate` will recreate this PR, overwriting any edits
    that have been made to it
    - `@dependabot show <dependency name> ignore conditions` will show all
    of the ignore conditions of the specified dependency
    - `@dependabot ignore this major version` will close this PR and stop
    Dependabot creating any more for this major version (unless you reopen
    the PR or upgrade to it yourself)
    - `@dependabot ignore this minor version` will close this PR and stop
    Dependabot creating any more for this minor version (unless you reopen
    the PR or upgrade to it yourself)
    - `@dependabot ignore this dependency` will close this PR and stop
    Dependabot creating any more for this dependency (unless you reopen the
    PR or upgrade to it yourself)

    </details>

    Signed-off-by: dependabot[bot] <support@github.com>
    Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

commit 9283179d1176a56985d38f1a3c92a05e6015c68b
Author: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Date:   Fri Jul 10 03:43:38 2026 -0700

    chore: bump max-sixty/tend from 0.1.9 to 0.1.10 (#3404)

    Bumps [max-sixty/tend](https://github.com/max-sixty/tend) from 0.1.9 to
    0.1.10.
    <details>
    <summary>Release notes</summary>
    <p><em>Sourced from <a
    href="https://github.com/max-sixty/tend/releases">max-sixty/tend's
    releases</a>.</em></p>
    <blockquote>
    <h2>0.1.10</h2>
    <h3>Improved</h3>
    <ul>
    <li><strong>The system prompt gains an explicit priority
    ordering.</strong> A new <code>Priorities</code> section in the shared
    system prompt, loaded by every harness, ranks (1) being pro-social, (2)
    making the project excellent, and (3) helping individual users — so when
    an individual's workaround and the durable project-level fix pull apart,
    the bot foregrounds the durable fix. The triage skill adds a matching
    &quot;apply the project lens&quot; step before replying. (<a
    href="https://github.com/max-sixty/tend/pull/758">#758</a>)</li>
    <li><strong>The <code>tend-outage</code> failure reporter renders every
    entry as one table.</strong> Follow-up failures on an open outage issue
    now append the same one-row <code>When | Run | Trigger</code> table the
    issue body uses, instead of a bespoke one-liner, so a single outage
    issue reads uniformly. (<a
    href="https://github.com/max-sixty/tend/pull/748">#748</a>)</li>
    <li><strong>Both Claude harnesses update to claude-code
    2.1.201.</strong> (<a
    href="https://github.com/max-sixty/tend/pull/755">#755</a>)</li>
    </ul>
    <h3>Fixed</h3>
    <ul>
    <li><strong>Bot commits and PRs are attributed solely to the tend
    bot.</strong> Both Claude harness actions now set <code>attribution:
    {commit: &quot;&quot;, pr: &quot;&quot;}</code> in the agent's settings,
    emptying Claude Code's auto-added <code>Co-Authored-By</code> commit
    trailer and &quot;Generated with Claude Code&quot; PR footer, and the
    triage, ci-fix, and review skill templates drop their hard-coded
    <code>Co-Authored-By</code> lines. (<a
    href="https://github.com/max-sixty/tend/pull/760">#760</a>)</li>
    <li><strong><code>tend-mention</code> no longer runs a no-op session on
    the bot's own APPROVED review.</strong> The bot's empty-body approval is
    terminal, but its <code>pull_request_review</code> event passed the
    engagement check (which counts that very review) and spun up a session
    with nothing to do. The verify job now skips when the review is the
    bot's own approval with an empty body — a bot review requesting changes,
    commenting, or approving with body text still fires. (<a
    href="https://github.com/max-sixty/tend/pull/749">#749</a>)</li>
    </ul>
    </blockquote>
    </details>
    <details>
    <summary>Changelog</summary>
    <p><em>Sourced from <a
    href="https://github.com/max-sixty/tend/blob/main/CHANGELOG.md">max-sixty/tend's
    changelog</a>.</em></p>
    <blockquote>
    <h2>0.1.10</h2>
    <h3>Improved</h3>
    <ul>
    <li><strong>The system prompt gains an explicit priority
    ordering.</strong> A new <code>Priorities</code> section in the shared
    system prompt, loaded by every harness, ranks (1) being pro-social, (2)
    making the project excellent, and (3) helping individual users — so when
    an individual's workaround and the durable project-level fix pull apart,
    the bot foregrounds the durable fix. The triage skill adds a matching
    &quot;apply the project lens&quot; step before replying. (<a
    href="https://github.com/max-sixty/tend/pull/758">#758</a>)</li>
    <li><strong>The <code>tend-outage</code> failure reporter renders every
    entry as one table.</strong> Follow-up failures on an open outage issue
    now append the same one-row <code>When | Run | Trigger</code> table the
    issue body uses, instead of a bespoke one-liner, so a single outage
    issue reads uniformly. (<a
    href="https://github.com/max-sixty/tend/pull/748">#748</a>)</li>
    <li><strong>Both Claude harnesses update to claude-code
    2.1.201.</strong> (<a
    href="https://github.com/max-sixty/tend/pull/755">#755</a>)</li>
    </ul>
    <h3>Fixed</h3>
    <ul>
    <li><strong>Bot commits and PRs are attributed solely to the tend
    bot.</strong> Both Claude harness actions now set <code>attribution:
    {commit: &quot;&quot;, pr: &quot;&quot;}</code> in the agent's settings,
    emptying Claude Code's auto-added <code>Co-Authored-By</code> commit
    trailer and &quot;Generated with Claude Code&quot; PR footer, and the
    triage, ci-fix, and review skill templates drop their hard-coded
    <code>Co-Authored-By</code> lines. (<a
    href="https://github.com/max-sixty/tend/pull/760">#760</a>)</li>
    <li><strong><code>tend-mention</code> no longer runs a no-op session on
    the bot's own APPROVED review.</strong> The bot's empty-body approval is
    terminal, but its <code>pull_request_review</code> event passed the
    engagement check (which counts that very review) and spun up a session
    with nothing to do. The verify job now skips when the review is the
    bot's own approval with an empty body — a bot review requesting changes,
    commenting, or approving with body text still fires. (<a
    href="https://github.com/max-sixty/tend/pull/749">#749</a>)</li>
    </ul>
    </blockquote>
    </details>
    <details>
    <summary>Commits</summary>
    <ul>
    <li><a
    href="https://github.com/max-sixty/tend/commit/bdfbdd40ac9455db7082b8abca040225503e933e"><code>bdfbdd4</code></a>
    chore: release 0.1.10 (<a
    href="https://github.com/max-sixty/tend/issues/763">#763</a>)</li>
    <li><a
    href="https://github.com/max-sixty/tend/commit/6e58d2ef510bd51898cbb14dec0ffd09fd5dc28d"><code>6e58d2e</code></a>
    fix: attribute bot commits and PRs solely to the tend bot (<a
    href="https://github.com/max-sixty/tend/issues/760">#760</a>)</li>
    <li><a
    href="https://github.com/max-sixty/tend/commit/75240861f0ac25ac54713b77395f36fb3dcb4f1b"><code>7524086</code></a>
    prompt: make project excellence an explicit priority (<a
    href="https://github.com/max-sixty/tend/issues/758">#758</a>)</li>
    <li><a
    href="https://github.com/max-sixty/tend/commit/1a505d4649980668d5082440d3465a5c2aca19ff"><code>1a505d4</code></a>
    chore(review-reviewers): bump action pin to claude-interactive@0.1.9 (<a
    href="https://github.com/max-sixty/tend/issues/754">#754</a>)</li>
    <li><a
    href="https://github.com/max-sixty/tend/commit/668dce805f035f4b3b14ddb15922f92dbd5b6144"><code>668dce8</code></a>
    chore: bump claude_version to 2.1.201 (<a
    href="https://github.com/max-sixty/tend/issues/755">#755</a>)</li>
    <li><a
    href="https://github.com/max-sixty/tend/commit/891df36b1d7ec493733644e616dde6946c639763"><code>891df36</code></a>
    fix: skip no-op tend-mention session on bot's own APPROVED review (<a
    href="https://github.com/max-sixty/tend/issues/749">#749</a>)</li>
    <li><a
    href="https://github.com/max-sixty/tend/commit/8bcb1537181c78c252d463e2b0fb8541e61f00af"><code>8bcb153</code></a>
    refactor(report-failure): render every outage entry as one table (<a
    href="https://github.com/max-sixty/tend/issues/748">#748</a>)</li>
    <li><a
    href="https://github.com/max-sixty/tend/commit/a28e618205c3f740c9a3b2c9f6a09cea00439156"><code>a28e618</code></a>
    chore: regenerate workflows with tend 0.1.9 (<a
    href="https://github.com/max-sixty/tend/issues/746">#746</a>)</li>
    <li>See full diff in <a
    href="https://github.com/max-sixty/tend/compare/0.1.9...0.1.10">compare
    view</a></li>
    </ul>
    </details>
    <br />

    [![Dependabot compatibility
    score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=max-sixty/tend&package-manager=github_actions&previous-version=0.1.9&new-version=0.1.10)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

    Dependabot will resolve any conflicts with this PR as long as you don't
    alter it yourself. You can also trigger a rebase manually by commenting
    `@dependabot rebase`.

    [//]: # (dependabot-automerge-start)
    [//]: # (dependabot-automerge-end)

    ---

    <details>
    <summary>Dependabot commands and options</summary>
    <br />

    You can trigger Dependabot actions by commenting on this PR:
    - `@dependabot rebase` will rebase this PR
    - `@dependabot recreate` will recreate this PR, overwriting any edits
    that have been made to it
    - `@dependabot show <dependency name> ignore conditions` will show all
    of the ignore conditions of the specified dependency
    - `@dependabot ignore this major version` will close this PR and stop
    Dependabot creating any more for this major version (unless you reopen
    the PR or upgrade to it yourself)
    - `@dependabot ignore this minor version` will close this PR and stop
    Dependabot creating any more for this minor version (unless you reopen
    the PR or upgrade to it yourself)
    - `@dependabot ignore this dependency` will close this PR and stop
    Dependabot creating any more for this dependency (unless you reopen the
    PR or upgrade to it yourself)

    </details>

    Signed-off-by: dependabot[bot] <support@github.com>
    Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

commit 103e9a70abd418b34969358a8bafceb8c5e2e741
Author: Worktrunk Bot <w@worktrunk.dev>
Date:   Fri Jul 10 03:43:30 2026 -0700

    chore: update tend workflows (0.1.9 → 0.1.10) (#3413)

    Automated nightly regeneration of tend's workflow files, picking up tend
    0.1.10.

    **tend version:** 0.1.9 → 0.1.10

    **Notable changes:**

    - Skip a no-op `tend-mention` session when the only trigger is the bot's
    own empty-body *approved* review — the review is terminal, so there's
    nothing to act on (a bot `CHANGES_REQUESTED`/`COMMENTED` review, or an
    approval carrying nits in its body, still fires). This is the
    substantive body change in this regen (`tend-mention.yaml`)
    (max-sixty/tend#749).
    - Attribute bot commits and PRs solely to the tend bot account
    (max-sixty/tend#760).
    - `report-failure` now renders every outage entry as a single table in
    `tend-outage` issues (max-sixty/tend#748).
    - Prompt update: make project excellence an explicit priority for the
    bot (max-sixty/tend#758).

    Compare: https://github.com/max-sixty/tend/compare/0.1.9...0.1.10

    Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>

commit 26f66585228c9aa627fcf6d82a1073c059c1935f
Author: Worktrunk Bot <w@worktrunk.dev>
Date:   Fri Jul 10 03:37:13 2026 -0700

    fix: contain termimad panic on ragged tables in PR comments (#3408)

    ## Problem

    Running `wt switch` (the interactive picker) crashes with:

    ```
    thread '<unnamed>' panicked at termimad-0.34.1/src/tbl.rs:176:30:
    index out of bounds: the len is 7 but the index is 7
    Rayon: detected unexpected panic; aborting
    ```

    The panic originates in the `termimad` crate (latest version, 0.34.1 —
    no upstream fix available), reached via the picker's PR-comments preview
    pane: `picker::prs::render_comment_blocks` →
    `md_help::render_table_with_termimad` → `MadSkin::text`.

    **Root cause (upstream bug):** termimad's column fitter,
    `Table::fix_columns`, panics on a *ragged* table — one where some row
    has more cells than the header — when it's rendered at a width too
    narrow to fit the widest row. In that case the fitter's `TblFit::new`
    fails and it takes an error branch that **skips** padding the short rows
    to the column count, then the alignment loop indexes past them
    (`cells[ic]` for `ic in 0..nbcols`). Equal-column tables never hit this
    at any width; only ragged ones do.

    PR/MR comment markdown is untrusted and can contain such a table.
    Because the comments pane renders on a **rayon worker**, the escaped
    panic aborts the entire process rather than just failing one render.

    ## Solution

    Contain the upstream panic at the single termimad call site
    (`render_table_with_termimad` in `src/md_help.rs`) with
    `std::panic::catch_unwind`, falling back to the plain preprocessed table
    lines so the pane still shows the table's text. This keeps the panic
    from escaping to the rayon worker and taking down `wt switch`, and
    guards the whole class of termimad table panics rather than one specific
    input.

    ## Testing

    Added `test_render_table_ragged_narrow_does_not_panic` in
    `src/md_help.rs`, which renders a ragged table (`| Key | Value |` header
    over a six-column data row) at a narrow width. Verified it panics at
    `tbl.rs:176:30` without the fix (same location as the report) and passes
    with it. Full `md_help` suite (26 tests) and `cargo clippy` are green.

    ---
    Closes #3407 — automated triage

    ---------

    Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
    Co-authored-by: Claude <noreply@anthropic.com>

commit 57b303fda253dc80340133807f6d8833f6b47ec0
Author: Maximilian Roos <5635139+max-sixty@users.noreply.github.com>
Date:   Thu Jul 9 23:29:40 2026 -0700

    fix(nix): provide lsof to the flake's test sandbox (#3410)

    The nix-flake job has failed on main since #3396:
    `test_remove_reap_kills_process` discovers its child via `lsof -d cwd`,
    but the flake's `worktrunk-tests` check didn't provide `lsof`, so
    `processes_under` fails to spawn it, returns an empty list forever, and
    the test's 5s discovery poll panics with `child <pid> never discovered`.
    Failing main run:
    https://github.com/max-sixty/worktrunk/actions/runs/29066326142.

    Three changes:

    - `flake.nix`: add `pkgs.lsof` to the test check's inputs, the same
    pattern as `git`/`python3`/`procps`. `ps` (procps) already works in the
    sandbox, so /proc-based process inspection is available there and the
    test should be genuinely exercised rather than excluded.
    - `.github/workflows/nightly.yaml`: add `flake.nix`, `flake.lock`, and
    `nix/**` to the trigger paths (push and PR gate). A flake change
    couldn't previously trigger the nix-flake job that validates it; this PR
    relies on the fixed trigger for its own validation.
    - `tests/integration_tests/remove.rs`: point the discovery-timeout panic
    message at lsof, so the next environment missing it doesn't need
    re-diagnosing.

    Validation: nix isn't available locally, so the proof is this PR's own
    nightly run going green on the nix-flake job.

    > _This was written by Claude Code on behalf of max_

    Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

commit 0b413ae3b21bb4976474c1b28e9db94dcd268017
Author: Maximilian Roos <5635139+max-sixty@users.noreply.github.com>
Date:   Thu Jul 9 22:35:47 2026 -0700

    fix(list): reserve prompt rows so the table doesn't jump at exit (#3409)

    `wt list`'s progressive table sits stable on screen through the loading
    phase, then jumps up at an unpredictable moment: the command exits and
    the shell prints its multi-line prompt, and the terminal scrolls to make
    room. Every other command scrolls too, but there the output scroll and
    the prompt scroll merge into one Enter-time event; the progressive
    table's delay between paint and exit is what makes the jump read as a
    jerk.

    The renderer now emits two blank rows below the footer with the skeleton
    and moves the cursor back over them. The prompt renders into those
    pre-scrolled rows, so the settled table doesn't move at exit; the scroll
    happens at Enter-time with the skeleton paint, where scroll is expected.

    The exact prompt height is unknowable, but the failure modes are
    asymmetric, so a constant works: extra reserved rows are consumed
    invisibly by whatever prints next (verified in tmux: a 1-line prompt
    leaves scrollback byte-identical to before), while a prompt taller than
    3 rows scrolls by just the difference. Two rows absorb fish's default
    (1), starship's default (2), and tide/powerlevel10k (typically 3).

    Details:

    - The viewport budget goes from `h − 4` to `h − 6` so a bottom-pinned
    skeleton plus reserve can't scroll its own header off, which would break
    the `MoveUp` redraw math. Short terminals show two fewer skeleton rows;
    the overflow finalize still prints the full table at the end.
    - The overflow finalize path re-emits the reserve after its reprint.
    - The resting cursor position (line after the footer) is unchanged, so
    the in-place update code needed no changes.
    - The PTY test harness now exposes the final cursor position; the
    overflow test asserts the cursor rests two reserved rows above the
    bottom, and the fast-command test pins the resting position (catching an
    emit-without-`MoveUp` mismatch).

    Verified end-to-end in tmux (fish, 3-row prompt, full screen, 11
    worktrees): the header row stays fixed across run, exit, and prompt,
    where the released binary jumps two rows. The error path (post-table
    error blocks printed after finalize) consumes the reserve and keeps
    today's behavior, which is fine: new content appearing at completion
    legitimately grows the screen.

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

    > _This was written by Claude Code on behalf of max_

    Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

commit 958b3082de22ad9f518eec7aab78bd9e4508b7f5
Author: Maximilian Roos <5635139+max-sixty@users.noreply.github.com>
Date:   Thu Jul 9 22:08:33 2026 -0700

    fix(env): scrub GIT_* discovery vars from for-each and --execute fallback (#3400)

    #3374 scrubbed inherited `GIT_*` discovery vars from user-hook spawns,
    but two more spawn sites relocate a user command into a wt-chosen
    worktree and had the same bug: `wt step for-each` and the `--execute`
    payload when wt executes it directly (no shell integration — including
    every `!wt`-alias invocation, which bypasses the wrapper). Git resolves
    `GIT_DIR`/`GIT_WORK_TREE` before walking up from the cwd, so the
    inherited value silently overrode the worktree wt placed the command in:
    `git w step for-each -- git rev-parse --show-toplevel` from a linked
    worktree printed the invoking worktree's path for every iteration while
    the headers named each worktree. The linked-worktree alias case is the
    common trigger — git exports an absolute `GIT_DIR` pinned to the
    invoking worktree's private gitdir there (verified on git 2.54; from a
    main-worktree root it exports none).

    The rule is now stated once, on `scrub_git_discovery_env_vars`: **scrub
    exactly when wt chose the child's cwd** (hooks, for-each, the
    `--execute` fallback); keep when the command runs in the user's own
    context (aliases, `commit.generation`, and wt's internal plumbing, which
    keeps the absolutize-and-forward behavior from #1914). A CLAUDE.md rule
    points there, mirroring the `CommandTrace` any-new-spawn-site
    convention.

    Testing: regression tests for both new scrub sites (each validated by
    reverting the fix and watching it fail) plus an alias pass-through test
    pinning the keep side. The `--execute` test is cross-platform, so
    Windows CI drives the non-unix `spawn` variant and unix the `exec`
    variant; the for-each test follows the file's unix `sh -c` precedent.

    Ref #3373 (already closed by #3374; this completes the sweep).

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

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

    ---------

    Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

commit 5e58827a2858972080d47840b649ab70852bfaf7
Author: Maximilian Roos <5635139+max-sixty@users.noreply.github.com>
Date:   Thu Jul 9 21:35:28 2026 -0700

    bench(prune): rust-scale prune fixture, criterion benches, and removal trace spans (#3401)

    Makes `wt step prune` / `wt remove` staging performance measurable and
    reproducible. Users report prune taking many seconds while the synthetic
    benches scan in ~150 ms; this adds the observability and fixtures that
    reproduce the gap and attribute it. No `wt` runtime behavior changes —
    the `src/` diff is ~29 lines of trace spans.

    ## What a reviewer needs

    - **Trace spans** (`src/commands/step/prune.rs`,
    `src/commands/process.rs`, `src/git/fsmonitor.rs`): `prune-gather` /
    `prune-scan` / `prune-check:<ref>` / `prune-remove:<label>` plus
    `internal-sweep` around `wt remove`'s end-of-command janitor. A
    `prune-remove` span starts before the write-lock acquisition, so it
    reads as "how long this removal stalled the run" (documented in
    benches/CLAUDE.md).
    - **Fixtures** (`tests/helpers/wt-perf/src/lib.rs`, the bulk of the
    diff): `prune-M-U` — M squash-merged candidate pairs
    (content-integrated, the post-PR-squash shape) against a
    two-sided-diverged backdrop forked across history; `prune-real[-M-U]` —
    the same shape on a rust-lang/rust clone, default 12+24 → 36 linked
    worktrees. The real fixture is cache-managed under `target/bench-repos/`
    (~15 GiB, minutes to build): `ensure_prune_real_repo` classifies it
    Intact/Consumed/Broken on reuse, re-creates candidates consumed by a
    live prune in ~1 min (round derived from the surviving squash commits,
    no sidecar state), and heals invalidated worktree indexes.
    - **Benchmarks** (`benches/prune.rs`): criterion groups `prune_e2e`
    (dry-run cold/warm + live with per-iteration candidate re-creation) and
    `prune_real_repo` (warm dry-run only). The real group is gated behind a
    new `real-repo-benches` cargo feature so the nightly benchmarks workflow
    — plain `cargo bench` on a hosted runner — never builds a fixture bigger
    than the runner's disk and the actions cache cap.

    ## Measured (M-series Mac, in benches/CLAUDE.md)

    Live prune on the rust-scale fixture: **~12 s wall** — a 5.4 s stat-cold
    scan (36 `git status` at ~4.5 s each, absorbed by the rayon pool) plus
    24 removals serialized under the scan write lock (~0.5–1.7 s per
    worktree candidate). Warm re-scan: 0.25–0.8 s. This reproduces the
    reported "prune takes seconds" experience and points optimization at
    status cost and removal overlap, not the integration probes.

    Rider fix picked up en route:
    `docs.anthropic.com/en/docs/build-with-claude/claude-code` now 404s
    (failing lychee on every push, including on main), so the llm-commits
    installation link and the `wt config plugins` install-hint now point at
    `code.claude.com/docs/en/setup`.

    Also fixes a latent fixture bug: `history_spread_shas` divided by its
    hardcoded 5000-commit cap, so on short synthetic histories every
    "history-spread" fork silently collapsed to the tip.

    ## Testing

    wt-perf unit tests cover the fixture lifecycle (state classification,
    derived repair rounds, squash content-integration); every
    `ensure_prune_real_repo` path (build, cached, repair, index-heal,
    foreign cwd) was exercised end-to-end locally, as were both criterion
    groups and the one-shot timelines. An integration test asserts the `wt
    remove -vv` trace surfaces the fsmonitor sweep (span plus daemon count),
    pinning the sweep's observability contract. The rust-scale numbers are
    I/O-bound and ambient-load-sensitive — documented as shape, not
    thresholds.

    > _This was written by Claude Code on behalf of max_

    ---------

    Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

commit c8de0d872a90f318d98e9303a0d44fed3d9358aa
Author: Maximilian Roos <5635139+max-sixty@users.noreply.github.com>
Date:   Thu Jul 9 20:12:03 2026 -0700

    ci: move lychee link check to the nightly workflow (#3402)

    Moves the lychee link check out of PR CI into the `nightly` workflow.
    Link health depends on external sites (429s, bot-blocking, link rot),
    which made `lint` the flakiest PR check, and breakage isn't correlated
    with the PR that trips it: the most recent main run's lint job went red
    on the docs.anthropic.com restructure (fixed in #3399) with no code
    change involved.

    The hook stays defined once in `.pre-commit-config.yaml`, moved to the
    `manual` stage, so the PR `lint` job, local `pre-commit run
    --all-files`, and the `wt merge` gate all skip it (the wt.toml Windows
    conditional existed only to skip lychee and collapses). The new
    `link-check` nightly job runs that stage with the pinned lychee and is
    wired into `create-issue-on-nightly-failure`, so breakage lands in the
    nightly-failure issue for tend instead of blocking unrelated PRs.
    Running through pre-commit rather than `git ls-files | xargs lychee`
    keeps file selection at one definition: pre-commit's `types: [file]`
    filter excludes the 16 tracked `.md`/`.txt` symlinks, which would
    otherwise false-positive on relative links resolved against the
    symlink's directory.

    The trade: a PR that introduces a genuinely wrong URL now merges green
    and is caught up to a day later, asynchronously.

    Verified locally that the default stage no longer selects the hook and
    that `pre-commit run lychee-system --all-files --hook-stage manual`
    reproduces the CI lint run's selection exactly (same two flagged
    inputs). The nightly job itself first runs on the next cron or a
    `workflow_dispatch`.

    > _This was written by Claude Code on behalf of max_

    Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

commit 9b2cbc6a8c2852a30e7fff393f8343f3558c10ed
Author: Worktrunk Bot <w@worktrunk.dev>
Date:   Thu Jul 9 19:42:57 2026 -0700

    docs(llm-commits): fix 404 Claude Code docs link (#3399)

    ## Problem

    The `lint` CI job (its `lychee-system` link-check step) fails on the
    default branch:

    ```
    [404] https://docs.anthropic.com/en/docs/build-with-claude/claude-code | Rejected status code: 404 Not Found | Followed 2 redirects: --[301]--> platform.claude.com/docs/en/docs/build-with-claude/claude-code --[307]--> platform.claude.com/docs/en/build-with-claude/claude-code
    ```

    Anthropic migrated its docs and the old `build-with-claude/claude-code`
    path now redirects to a dead `platform.claude.com` URL. The link was
    green when it last ran on `main`; the upstream migration broke it since,
    so the next push to the default branch fails `lint`.

    ## Solution

    Point the link at
    `https://docs.anthropic.com/en/docs/claude-code/overview` (verified
    `200`), which is the same URL form already used elsewhere in the repo.
    The link lives in `docs/content/llm-commits.md` (the primary source for
    this non-command doc); `skills/worktrunk/reference/llm-commits.md` is
    the generated mirror, regenerated via `test_docs_are_in_sync`.

    ## Testing

    - `curl -sIL https://docs.anthropic.com/en/docs/claude-code/overview` →
    `200`.
    - `cargo test --test integration test_docs_are_in_sync` passes (source
    and skill mirror in sync).
    - No `build-with-claude/claude-code` occurrences remain under `docs/` or
    `skills/`.

    ---
    Separated from the [PTY-flake CI fix
    (#3398)](https://github.com/max-sixty/worktrunk/pull/3398) to keep each
    concern atomic. Surfaced while fixing [run
    29053905721](https://github.com/max-sixty/worktrunk/actions/runs/29053905721).

    Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
    Co-authored-by: Claude <noreply@anthropic.com>

commit 1d46c0083b6680f22a1e6306b38e326ef9b7183c
Author: Worktrunk Bot <w@worktrunk.dev>
Date:   Thu Jul 9 19:42:48 2026 -0700

    fix(test): treat Linux PTY EIO-on-close as EOF in read helpers (#3398)

    ## Problem

    The `code-coverage` job failed on [run
    29053905721](https://github.com/max-sixty/worktrunk/actions/runs/29053905721)
    (default branch, commit f593438) with a panic in a PTY test:

    ```
    thread '...test_branch_name_with_dashes_underscores::case_3' panicked at tests/common/pty.rs:44:41:
    ```

    Line 44 was `reader.read_to_string(&mut buf).unwrap()` in the Unix
    branch of `read_pty_output`. Only `case_3` (fish) panicked;
    `case_1`/`case_2` (bash/zsh) passed in the same run — the flaky
    signature.

    **Root cause:** On Linux, a `read` on a PTY master returns `EIO` once
    the child exits and closes the slave side, instead of the clean 0-byte
    EOF macOS returns. `read_to_string` propagates that `EIO` as an
    `io::Error`, and `.unwrap()` panics. Whether the read observes the `EIO`
    or a clean EOF depends on whether it raced ahead of or behind the
    child's exit — hence the intermittent, per-case failure.

    This is the Linux face of the same fragile read that #3144 diagnosed as
    a macOS read-to-EOF timeout; that issue explicitly flagged a more robust
    read in this path as the escalation if the flake recurred.

    ## Solution

    Extract a shared `read_pty_master_to_string` helper that reads to
    end-of-stream and treats `EIO` as EOF on Unix (a plain `read_to_string`
    on Windows/ConPTY, behavior unchanged). Route both PTY-master reads that
    shared the fragile `read_to_string().unwrap()` pattern through it:

    - `read_pty_output` (`tests/common/pty.rs`) — the shell-wrapper and
    README-example PTY path.
    - `execute_shell_script` (`tests/common/shell.rs:108`) — the e2e-shell
    path, which had the identical pattern and the same latent flake.

    `libc` (already present transitively via `portable-pty`) is added to
    `[dev-dependencies]` for the canonical `EIO` constant.

    ## Testing

    - `cargo test --test integration --features shell-integration-tests` for
    `test_branch_name_with_dashes_underscores` and
    `test_source_flag_forwards_errors` (bash/zsh/fish cases) — all pass.
    `case_4` (nu) is only skippable locally because nushell isn't installed
    in the CI-fix sandbox; it fails at *spawn*, not the read path, and
    passed in the original CI run.
    - `e2e_shell::*` (7 tests exercising `execute_shell_script`) — all pass.
    - `cargo clippy --tests --features shell-integration-tests` and `cargo
    fmt --check` — clean.

    ---
    Automated fix for [failed
    run](https://github.com/max-sixty/worktrunk/actions/runs/29053905721)

    ---------

    Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
    Co-authored-by: Claude <noreply@anthropic.com>

commit 745f6a90df0b15f437983493ad4e6ef58980bc63
Author: Maximilian Roos <5635139+max-sixty@users.noreply.github.com>
Date:   Thu Jul 9 18:11:48 2026 -0700

    refactor(hook): share command-label rendering with approvals surfaces (#3397)

    Follow-up to #3380: `wt hook show` still hand-built the `{phase} name:`
    command label that `ApprovableCommand::label()` renders for the approval
    prompt and `wt config approvals`. This extracts the format into a
    `command_label` free function in `project_config.rs` and calls it from
    both. It's free-standing rather than a method on `ApprovableCommand`
    because hook show also labels user hooks, which are never approvable.

    Output is byte-identical — no snapshot changes.

    > _This was written by Claude Code on behalf of max_

    Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

commit f593438e7c1b4898c82ed222e235f6d116974408
Author: Worktrunk Bot <w@worktrunk.dev>
Date:   Thu Jul 9 15:13:46 2026 -0700

    feat(remove): add experimental --reap to kill worktree processes (#3396)

    Implements the design agreed in #3365: an opt-in, experimental flag on
    `wt remove` that reaps processes left running in the worktree.

    ## The name

    `--reap` — it's the term the whole thread and the issue title already
    use, it's concise (matching worktrunk's flag style), and the user-facing
    messages read naturally (`◎ Reaping 2 processes under feature
    worktree`).

    ## What it does

    ```console
    $ wt remove --reap feature
    ◎ Reaping 2 processes under feature worktree
       ┃ 51234 node
       ┃ 51240 esbuild
    ✓ Reaped 2 processes
    ◎ Removing feature worktree & branch in background (same commit as main, _)
    ```

    Processes are discovered by working directory (`lsof -d cwd`): any
    process whose cwd is at or under the worktree path. Termination reuses
    the existing `SIGTERM`→wait→`SIGKILL` escalation (`escalate_terminate`,
    shared with the fsmonitor sweep).

    ## Data-safety posture

    Killing a process the user didn't mean to kill — a terminal editor with
    unsaved buffers — is exactly the silent loss-of-work the project refuses
    without consent, so two guards keep `--reap` conservative:

    - **Controlling-terminal exclusion.** A process holding a controlling
    terminal (an interactive shell, or `vim`/`nvim`/`emacs -nw`) is never
    reaped (`ps -o tty=`). Only detached processes — the dev servers and
    watchers this issue is about — remain candidates. This also spares the
    shell `wt remove` was run from.
    - **Self-exclusion.** The current `wt` process is never a candidate.

    The flag itself is the explicit opt-in; the list is printed before
    signalling for transparency.

    ## Scope / limitations (matching the #3365 discussion)

    - **Under-inclusive by design.** cwd discovery misses a daemon that
    forked and `chdir`'d away, or one that reparented to `init` — they no
    longer report a cwd under the path. Those are what [`wt step
    tether`](https://worktrunk.dev/step/#wt-step-tether) is built to reap
    (whole process group). `--reap` and `tether` cover different gaps and
    are complementary, not substitutes — the docs say so.
    - **Ordering.** Reaping runs before the worktree directory is
    staged/renamed (cwd matching needs the directory in place), so it's
    independent of foreground/background removal, trash-vs-delete, and
    `--force`.
    - **Unix only.** Windows has no cheap per-process cwd; `--reap` is
    rejected there with a clear error.

    ## Tests

    - Pure parsers for `lsof`/`ps` output (`parse_lsof_cwd`,
    `parse_ps_tty`).
    - End-to-end against the real `lsof`/`ps`: spawns a child with a cwd
    under a tempdir, asserts `processes_under` discovers it, asserts the
    controlling-terminal guard keeps-or-drops it in agreement with the
    child's *actual* TTY state (so the test is host-independent — CI has no
    TTY, a dev box does), then reaps it and confirms `SIGTERM`.
    - CLI snapshot (`test_remove_reap_no_processes`) covering the
    no-candidates path, deterministic whether or not `lsof` is installed on
    the runner.

    Help text, `docs/content/remove.md`, and the skill reference mirror are
    regenerated and in sync.

    One thing worth a maintainer's eye: I chose to print-then-signal rather
    than add an interactive confirm/`--dry-run` in this first cut — the
    opt-in flag + TTY exclusion + printed list felt like enough for an
    experimental flag, and a confirm step is easy to layer on if you'd
    prefer it.

    Closes #3365.

    ---------

    Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
    Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
    Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>

commit d96eedb747d315949a6d884280010bb2b88a83b0
Author: Worktrunk Bot <w@worktrunk.dev>
Date:   Thu Jul 9 12:48:51 2026 -0700

    refactor(test): extract truncate_details_section helper (#3393)

    Follow-up to #3328, per @max-sixty's
    [request](https://github.com/max-sixty/worktrunk/pull/3328#discussion_r3554171596).

    `normalize_report` in `tests/integration_tests/diagnostic.rs` had three
    near-identical truncation blocks — for the **Environment variables**,
    **Performance profile**, and **Trace log** `<details>` sections. Each
    did the same find-summary → find-`</details>` → splice-placeholder
    dance, differing only in the section title and placeholder string.

    This extracts a `truncate_details_section(&mut result, title,
    placeholder)` helper and collapses all three call sites to one-liners.
    Net −9 lines, and adding a fourth truncated section is now one call
    instead of a copied block.

    Behaviour is unchanged: the existing
    `test_diagnostic_report_file_format` snapshot (which runs
    `normalize_report`) and `test_diagnostic_includes_environment_variables`
    both pass without regenerating the snapshot, confirming the helper
    produces byte-identical output to the three inlined blocks.

    🤖 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.8 <noreply@anthropic.com>

commit adb417ede817273d51ac5736924f7dc2bb41f5ff
Author: Worktrunk Bot <w@worktrunk.dev>
Date:   Thu Jul 9 12:23:20 2026 -0700

    fix(config): keep `wt config show` from suspending on the zsh compinit probe (#3327)

    ## Problem

    After upgrading 0.56.0 → 0.63.0, `wt config show` suspends instead of
    running:

    ```
    $ wt config show
    [1]  + 3071417 suspended (tty output)
    ```

    `suspended (tty output)` is SIGTTOU: a process in a **background**
    process group touched the controlling terminal. The reporter's `wt -vv
    config show` shows the suspend landing right after the log-path header
    and before any config content — i.e. the pager (`less`) is being
    suspended on its first terminal operation.

    ## Root cause

    `wt config show` runs an interactive zsh probe to detect whether
    `compinit` is configured (`check_zsh_compinit_missing`, mirrored by
    `shell::detect_zsh_compinit`). An interactive `zsh -ic` with a
    controlling terminal enables job control: it `setpgid`s itself into a
    new process group and `tcsetpgrp`s to claim the terminal **foreground**.

    [#3165](https://github.com/max-sixty/worktrunk/pull/3165) (shipped in
    0.62.0, inside the reporter's upgrade window) added a 2s
    **kill-on-timeout** to that probe. When the user's interactive rc is
    slow to start or blocks on a prompt (e.g. compinit's
    insecure-directories prompt), the probe is **SIGKILLed before it
    restores the foreground process group**. SIGKILL can't be caught, so
    zsh's terminal-restore-on-exit never runs — the terminal foreground is
    left pointing at the dead probe's group, and `wt` is now a background
    process group. The pager `wt config show` spawns moments later then
    raises SIGTTOU on its first `tcsetattr`, suspending the command.

    This is environment-specific: it only fires when the probe actually
    times out (slow/prompting interactive rc), which is why it doesn't
    reproduce for everyone.

    ## Solution

    Pass `+m` (disable job control) to both interactive probes. With job
    control off, the probe never grabs `wt`'s controlling terminal, so a
    timeout-kill can't strand `wt` in a background process group. `+m`
    doesn't affect compinit/`compdef` detection — it only disables terminal
    foreground management the probe never needed.

    ## Testing

    Verified at the OS level under a PTY, replicating the probe's exact
    spawn (interactive `zsh -ic`, stdin/stdout/stderr redirected, slow rc,
    SIGKILL on timeout):

    - **Unpatched:** `tcgetpgrp` of the controlling terminal moves to the
    probe's process group and **stays there** after the kill (foreground
    stolen) — the next terminal op suspends with SIGTTOU.
    - **With `+m`:** the terminal foreground is **intact** after the kill,
    and `wt config show` reaches the pager normally.

    Added regression guards (`test_compinit_probe_disables_job_control`,
    `test_zsh_probe_disables_job_control`) asserting both probes pass `+m`
    before `-ic`. Full terminal-suspend behavior depends on a real
    controlling TTY + a slow zsh rc, so it isn't unit-testable in CI without
    flakiness; the guards lock in the load-bearing flag.

    ---
    Closes #3322 — automated triage

    ---------

    Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
    Co-authored-by: Claude <noreply@anthropic.com>

commit 29fa13dc9553e2b40d7856f3ca714b0b88abb8d4
Author: Worktrunk Bot <w@worktrunk.dev>
Date:   Thu Jul 9 12:13:47 2026 -0700

    docs(code-signing): add SignPath Foundation code-signing policy page (#3366)

    Addresses the first, gating step of option #1 (SignPath Foundation) from
    #3355 — the fix for the `Trojan:Win32/Wacatac.B!ml` false positive on
    the unsigned Windows binary.

    ## What this PR does

    Adds a **code signing policy** page at
    [worktrunk.dev/code-signing/](https://worktrunk.dev/code-signing/). A
    published policy page is a hard eligibility requirement for the
    [SignPath Foundation](https://signpath.org/terms.html) free OSS
    code-signing program, so it's the natural thing to land first — the
    application can't proceed without it. The page documents:

    - **Certificate provenance** — the required attribution ("Free code
    signing provided by SignPath.io, certificate by SignPath Foundation")
    and the HSM-held key.
    - **What's signed** — `git-wt.exe` in the Windows release archive /
    winget; nothing else.
    - **The build+sign pipeline** — tag-triggered, cargo-dist on GitHub
    runners, SignPath action for signing, all reproducible from public
    source.
    - **Project roles** (Author / Reviewer / Approver), **per-release manual
    approval**, and the **no-telemetry** privacy stance SignPath asks
    projects to state.

    The generated skill mirror
    (`skills/worktrunk/reference/code-signing.md`) and
    `docs/static/llms.txt` entry are produced by `test_docs_are_in_sync` —
    not hand-edited.

    > **Roster check:** the roles table lists @max-sixty as
    Author/Reviewer/Approver. Adjust if anyone else should hold signing
    authority before this is submitted to SignPath.

    ## What this PR deliberately leaves out

    The `release.yaml` signing job and the SignPath account are a
    **follow-up**, for two reasons:

    1. **The concrete slugs don't exist yet.** The SignPath action needs
    `organization-id`, `project-slug`, and `signing-policy-slug` — all
    issued *after* the project is registered and approved.
    2. **cargo-dist doesn't sign nativ…
max-sixty pushed a commit that referenced this pull request Jul 12, 2026
…und (#3422)

Follow-up to #3408, as promised in that thread: [@Canop published
termimad
0.35.1](#3408 (comment))
with the fix for the ragged-table panic
([Canop/termimad#77](Canop/termimad#77)). This
bumps to it and drops the `catch_unwind` workaround.

## What changed

- **`termimad` 0.34.1 → 0.35.1** (pulls transitive `minimad` 0.14 →
0.16).
- **Removed the `catch_unwind` guard** in `render_table_with_termimad` —
the only panic reachable through this path was the `Table::fix_columns`
out-of-bounds on a ragged narrow table, now fixed upstream. (The
broken-pipe panics termimad has had historically live in `print_text`,
an I/O path we don't use — we call `MadSkin::text`, which returns a
`String`.)
- **Regression test kept**, repurposed:
`test_render_table_ragged_narrow_does_not_panic` now guards against
reintroducing the panic via a downgrade rather than asserting the
plain-text fallback.

## Verification

Ran the original reproducer against a clean `termimad = "=0.35.1"`
scratch crate across the full width band — all render, none panic
(16/20/24 previously panicked):

```
width   8: ok    width  20: ok    width  60: ok
width  12: ok    width  24: ok    width 200: ok
width  16: ok    width  30: ok
width  40: ok
```

`md_help` suite (26), picker unit tests (226), and the integration
`test_help` / `test_docs_are_in_sync` snapshots (48) all pass. `cargo
clippy` clean.

## One rendering note

`minimad` 0.16 no longer treats a **single-dash** delimiter row (`| - |
- |`) as a table separator — it renders as cell content. Worktrunk only
ever emits **multi-dash** (`---`) separators (`render_data_table` builds
`| --- | ... |`, and every hand-authored help table uses `-----`), so
real help/list output is byte-for-byte unchanged — the help/docs
snapshot tests didn't move. The one test that used a single-dash input
was artificial; I switched it to the canonical `---` form.

<details><summary>Defense-in-depth tradeoff — your call</summary>

Dropping `catch_unwind` removes a general safety net around rendering
untrusted PR-comment markdown on a rayon worker (where an escaped panic
aborts the whole process). I removed it because (a) the only reachable
panic class here is fixed, and (b) keeping it would leave the fallback
branch untestable and uncovered — no input triggers it anymore, which
trips the coverage gate. If you'd rather retain the guard as
belt-and-suspenders against a future termimad regression, say the word
and I'll restore it with a `#[cfg]`-gated or otherwise coverage-exempt
shape.

</details>

---------

Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
@max-sixty

Copy link
Copy Markdown
Owner

Over in Canop/termimad#77 — the upstream issue for this panic — @Canop asked whether I'd open the fix PR or he should. I'm not able to open PRs (or comment) in repos outside this one, so I can't respond there or push the fix upstream myself. Two ways forward:

@worktrunk-bot we should change this guidance (maybe @tend-agent this is on you?)

It's correct that you shouldn't spam issues or PRs outside your area of ownership. But if a maintainer of a different project (or the project's policy) says it's OK for you to contribute, and it's helpful for the project you maintain, then you should do this. Probably this is a change in tend.

@worktrunk-bot

Copy link
Copy Markdown
Collaborator Author

Agreed — the current rule is unconditionally "no PRs/comments in other repos", which is right for spam but wrong when an upstream maintainer explicitly invites the contribution and it helps worktrunk. You're right that it's a tend change (it's a bundled skill, so it'd fire for every consumer, not just here), so I've filed it there: max-sixty/tend#769.

It proposes a bounded carve-out to the Scope rule — permit an upstream PR/comment when both hold: (1) an explicit invitation (a target-repo maintainer asks in-thread, or the repo's contributing policy welcomes it — not just inferred), and (2) it serves worktrunk (e.g. upstreaming a dependency fix we're working around). The anti-spam default stays; only the invited case is unblocked. Concrete skill edits are in the issue for @tend-agent / your review.

(This one's moot in practice now — @Canop shipped the fix in termimad 0.35.1 and #3422 bumps + drops the workaround — so it's purely so the next invited upstream contribution isn't blocked.)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Crash] wt switch crashes with index out of bounds: the len is 7 but the index is 7

3 participants