Skip to content

fix(task): resolve command and platform shell in task runner - #4555

Merged
aeppling merged 6 commits into
rtk-ai:developfrom
viniciusdsandrade:fix/task-platform-resolution
Oct 20, 2026
Merged

aeppling merged 6 commits into
rtk-ai:developfrom
viniciusdsandrade:fix/task-platform-resolution

Conversation

@viniciusdsandrade

@viniciusdsandrade viniciusdsandrade commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Summary

• Uses resolved_command and child_args in run_for_output to support PATHEXT wrappers (.cmd, .bat) and child argument escaping across platforms.
• Routes api_key_command execution through shell_command to execute via the platform default shell instead of hardcoded sh.
• Uses resolve_claude_dir in claude_project_dir so custom CLAUDE_CONFIG_DIR paths are honored when locating project transcripts.

Closes #4648

Context & Problem

In src/core/task_cmd.rs:

  1. api_key previously spawned std::process::Command::new("sh").arg("-c").arg(command). On Windows, sh is not typically available on PATH, causing task credential resolution via api_key_command to fail immediately. Spawning sh directly also violated repository security guidelines.
  2. run_for_output invoked std::process::Command::new(program).args(rest) directly without resolving executable extensions or escaping child arguments for Windows wrappers.
  3. claude_project_dir joined .claude directly from user home rather than consulting resolve_claude_dir(), ignoring custom configuration directories.

Solution

  1. In api_key, routed script execution through crate::core::shell::shell_command(command, None) and handled unrunnable outcomes cleanly.
  2. In run_for_output, used crate::core::utils::resolved_command(program) and .child_args(rest) to ensure proper wrapper detection and argument encoding.
  3. In claude_project_dir, used crate::hooks::init::resolve_claude_dir().
  4. Added unit tests for api_key_command stdout capture, nonzero exit handling, and empty output handling, as well as claude_project_dir resolution with custom config directories.
  5. Documented in docs/guide/pipelines/tasks.md which shell api_key_command runs under (sh on Linux/macOS, cmd on Windows), so a Git Bash command is known to need rewriting or an explicit bash -c.
  6. Added a #[cfg(windows)] test that resolves a hello.cmd wrapper from PATH through run_for_output; it re-runs the test binary with the wrapper's directory on the child's PATH rather than mutating the suite's environment, and hard-coded the expected transcript slug in the claude_project_dir test.

Test plan

• CI verification on GitHub Actions across Linux, macOS, and Windows matrix.
• Static code review of argument handling, types, and error propagation.
• cargo fmt --all && cargo clippy --all-targets && cargo test --all green locally on macOS; the Windows-only wrapper test runs on the windows-latest legs.

Use resolved_command and child_args in run_for_output to support PATHEXT
wrappers and child argument escaping across platforms. Route
api_key_command through shell_command to use the platform default shell
instead of hardcoding sh, and add tests for credential command execution.

Signed-off-by: Vinícius DS Andrade <[email protected]>
Use resolve_claude_dir instead of hardcoding the user home directory
so that custom CLAUDE_CONFIG_DIR configurations are respected when
locating project transcripts for displaced model tracking.

Signed-off-by: Vinícius DS Andrade <[email protected]>
@rtk-wshm-sync-bot

Copy link
Copy Markdown

wshm · Automated triage by AI

📊 Automated PR Analysis

🐛 Type bug-fix
🟡 Risk medium

Summary

This PR fixes cross-platform execution bugs in the task runner: it routes api_key_command execution through the platform-default shell instead of hardcoding sh, uses resolved_command/child_args in run_for_output to properly handle Windows PATHEXT wrappers and argument escaping, and makes claude_project_dir respect custom CLAUDE_CONFIG_DIR paths via resolve_claude_dir. Unit tests were added for api_key_command behavior and custom config dir resolution.

Review Checklist

  • Tests present
  • Breaking change
  • Docs updated

Analyzed automatically by wshm · This is an automated analysis, not a human review.

cargo fmt --check failed on the bail! added for an unrunnable
api_key_command, which is a gate the project requires before merge.
Formatting only, no behavior change.

@aeppling aeppling left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks, this covers all three points of #4648. I ran it merged onto current develop against a local stub upstream:

  • api_key_command output becomes the key, including commands with pipes and quotes.
  • digest -- <cmd> keeps arguments with spaces and quotes intact.
  • With CLAUDE_CONFIG_DIR set, the transcript is found and x-rtk-routed-from is sent (develop sends none). Without it, ~/.claude is still used.

A few small things:

  1. On Windows api_key_command now runs under cmd. Could you note that in the api_key_command row of docs/guide/pipelines/tasks.md (e.g. "run through sh on Linux/macOS, cmd on Windows")? A key command written for Git Bash needs rewriting, or an explicit bash -c.
  2. Could you add a #[cfg(windows)] test for the resolver part: a hello.cmd in a temp dir on PATH, and run_for_output(["hello"]) captures its output? That's the actual Windows failure, and nothing covers it yet.
  3. Nit: in claude_project_dir_respects_custom_config_dir, .unwrap() → .expect("utf-8 path"), and consider hard-coding the expected slug rather than re-deriving it the same way the code does.

Once CI has run on Windows this looks good to merge.

@aeppling

Copy link
Copy Markdown
Contributor

@viniciusdsandrade are you planning to pick up the three points, or would you prefer we merge as is? The fix works and CI is green on Windows, so either is fine: if you'd rather not, we'll add the docs line and the Windows test in a follow-up.

api_key_command now goes through the platform default shell rather than
a hardcoded sh, which on Windows means cmd. A key command written for
Git Bash silently stops working there unless it is rewritten or wrapped
in an explicit `bash -c`, so the upstream table says which shell runs
it, as asked in the review of rtk-ai#4555.

Signed-off-by: Vinícius DS Andrade <[email protected]>
Deriving the expected slug with the same map the code applies proves
only that the two agree, not that either is right. A fixed path with a
`.` and, on Windows, a `:` in it pins what the slug rule does to each
character, and the fixed path has no `to_str().unwrap()` to need.

Signed-off-by: Vinícius DS Andrade <[email protected]>
`Command::new("npm")` cannot start the `.cmd` wrapper npm installs as
on Windows, which is the failure `resolved_command` fixes in
run_for_output, and nothing exercised it. The test writes a hello.cmd
into a temporary directory and asserts a bare `hello` runs and its
output is captured.

`which` reads the process's own PATH, and the suite never changes the
process environment under the tests running beside it (set_var is also
unsafe on edition 2024 and the crate denies unsafe_code), so the test
runs the test binary again with the directory in front of the child's
PATH, filtered to its own name with --exact, and checks the re-run
reported the one test so a mismatched name cannot pass vacuously. The
two direct environment reads this takes are registered in
DIRECT_READS, beside the one utils.rs already has for the same PATH.

Signed-off-by: Vinícius DS Andrade <[email protected]>
@viniciusdsandrade

Copy link
Copy Markdown
Contributor Author

Picked up all three, thanks for the careful read.

  1. Docs — the api_key_command row in docs/guide/pipelines/tasks.md now says it runs through sh on Linux/macOS and cmd on Windows, so a command written for Git Bash needs rewriting there, or an explicit bash -c "…" (95e9c59).
  2. Windows test — command_tests::a_cmd_wrapper_on_path_is_run (#[cfg(windows)]) writes a hello.cmd into a temp dir and asserts run_for_output(["hello"]) captures its echo with exit 0 (0c164e5). One wrinkle: which reads the process's own PATH, and the suite never mutates the process environment under the tests running beside it (set_var is also unsafe on edition 2024, and the crate denies unsafe_code), so the test re-runs the test binary with the wrapper's directory prepended to the child's PATH, filtered to its own name with --exact, and checks the re-run reported 1 passed so a mismatched name can't pass vacuously. The two std::env::var_os reads this takes are registered in test_isolation::DIRECT_READS, beside the one utils.rs already has for the same PATH. I verified the re-exec mechanics locally on macOS with a hello shell script standing in for the .cmd; the .cmd half runs on the Windows legs here. If you'd rather have a plain set_var under #[allow(unsafe_code)], happy to swap.
  3. Nit — the expected slug is hard-coded now (/work/my.project → -work-my-project, C:\work\my.project → C--work-my-project), which also removes the .unwrap() (154df01).

@aeppling aeppling left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks @viniciusdsandrade, approving.

@aeppling
aeppling merged commit 5538d72 into rtk-ai:develop Oct 20, 2026
16 checks passed
@rtk-release-bot rtk-release-bot Bot mentioned this pull request Oct 20, 2026
ryanduguid pushed a commit to yungSNAT/PascalsLetter that referenced this pull request Nov 2, 2026
…r 2026) (#33)

* chore: contributor tooling

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* chore: drop BREAKING_CHANGES.md and the release simulation script

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(release): one release and one v-tag for every crate

release-please releases the root package only, with tags kept as
v<version>. The workspace members (engine, plugin, text) follow it as the
Rust strategy bumps them; proxy/ is listed as an extra file. A per-crate
setup would have published five releases with component tags, which the
installers, Homebrew and winget cannot resolve.

Also: the secret scanner's sk- label names no vendor, and the editions
page uses the same install script URL as the README.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(pipeline): route before an edit is refused; the hook load rejects tasks named after real commands; a blank message keeps a reason

- `route` at hook.before_tool.edit is E005. The hand-over sent the edit to a
  `generate` task, which rewrote the whole target from a spec and the
  reference alone. A route at `any` no longer hands an Edit to `generate`.
- The hook-path load applies the same reserved-command check as `validate`
  and `pipeline add`, so a `[task.make]` file is dropped there too instead
  of routing a command to `rtk make`, which runs make.
- An empty or blank `message` on a deny or route falls back to the default
  text, so no host ever receives an empty block reason.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(hooks): pipeline hook rules act on Claude Code only; the shared hook core is develop's again

decision.rs, rewrite_cmd.rs, hook_check.rs and the OpenCode, Pi, Hermes and
OpenClaw plugins are back to develop. Claude Code's hook consults the
pipeline from its own module (hook_cmd/claude_pipeline.rs, engine only)
before the 0.x flow: deny, route and answer block with their message, a
swap is only offered where develop would rewrite and always asks, and
with no pipeline file the hook is unchanged. `rtk rewrite` no longer
prints anything at exit 2.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* chore(deps): bump encoding_rs from 0.8.35 to 0.8.42

Bumps [encoding_rs](https://github.com/hsivonen/encoding_rs) from 0.8.35 to 0.8.42.
- [Commits](https://github.com/hsivonen/encoding_rs/compare/v0.8.35...v0.8.42)

---
updated-dependencies:
- dependency-name: encoding_rs
  dependency-version: 0.8.42
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>

* chore(deps): bump ignore from 0.4.25 to 0.4.32

Bumps [ignore](https://github.com/BurntSushi/ripgrep) from 0.4.25 to 0.4.32.
- [Release notes](https://github.com/BurntSushi/ripgrep/releases)
- [Changelog](https://github.com/BurntSushi/ripgrep/blob/master/CHANGELOG.md)
- [Commits](https://github.com/BurntSushi/ripgrep/compare/ignore-0.4.25...ignore-0.4.32)

---
updated-dependencies:
- dependency-name: ignore
  dependency-version: 0.4.33
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>

* chore(deps): bump serde from 1.0.228 to 1.0.229

Bumps [serde](https://github.com/serde-rs/serde) from 1.0.228 to 1.0.229.
- [Release notes](https://github.com/serde-rs/serde/releases)
- [Commits](https://github.com/serde-rs/serde/compare/v1.0.228...v1.0.229)

---
updated-dependencies:
- dependency-name: serde
  dependency-version: 1.0.229
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>

* ci(release): fix the rtk-proxy build on x86_64-unknown-linux-musl

sqlite-vec 0.1.9, linked through icm-store, declares uint8_t, uint16_t and
uint64_t from the BSD u_int*_t names, which musl does not provide. Define
them to the C99 types for that target so the daemon compiles there like
on every other target.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* fix(ng): preserve argument boundaries in tracking labels

* fix(task): resolve command and platform shell in task execution

Use resolved_command and child_args in run_for_output to support PATHEXT
wrappers and child argument escaping across platforms. Route
api_key_command through shell_command to use the platform default shell
instead of hardcoding sh, and add tests for credential command execution.

Signed-off-by: Vinícius DS Andrade <[email protected]>

* fix(task): honor custom claude config directory in transcript resolution

Use resolve_claude_dir instead of hardcoding the user home directory
so that custom CLAUDE_CONFIG_DIR configurations are respected when
locating project transcripts for displaced model tracking.

Signed-off-by: Vinícius DS Andrade <[email protected]>

* fix(gain): only suggest `rtk pipeline share` where the command exists

The Apache-2.0 edition is built without the pipeline engine, so it has no
`rtk pipeline` command, yet `rtk gain` and `rtk gain --watch` still told
its users to run `rtk pipeline share`. The hint now shows only in builds
that have the command, and the dashboard drops the empty panel.

Fixes #4557

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(proxy): honour RTK_PROXY_BIND in `llm-proxy start` and the agent launchers

The daemon and `rtk llm-proxy status` read RTK_PROXY_BIND, but
`rtk llm-proxy start`, `rtk claude` (and the other launchers) and the
`rtk gain` hint still used 127.0.0.1:8080. With another service on 8080,
`start` failed and `rtk claude` waited 15 s, then ran the agent unproxied.
All three now use the configured address; an explicit `--bind` still wins.

The SessionStart hook that `rtk init -g` registers now restarts the
daemon on the port the session's ANTHROPIC_BASE_URL points at, so it
cannot start it on a different port than Claude Code is calling.

After `rtk llm-proxy start --bind <addr>`, the instructions now include
`export RTK_PROXY_BIND=<addr>`, without which `status` and `rtk claude`
look for the daemon on the default port.

Fixes #4556

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* chore(deps): bump actions/upload-artifact from 4 to 7

Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 7.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v7)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <[email protected]>

* chore(deps): bump actions/checkout from 4 to 7

Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <[email protected]>

* chore(deps): bump googleapis/release-please-action from 4.4.1 to 5.0.0

Bumps [googleapis/release-please-action](https://github.com/googleapis/release-please-action) from 4.4.1 to 5.0.0.
- [Release notes](https://github.com/googleapis/release-please-action/releases)
- [Changelog](https://github.com/googleapis/release-please-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/googleapis/release-please-action/compare/5c625bfb5d1ff62eadeeb3772007f7f66fdcf071...45996ed1f6d02564a971a2fa1b5860e934307cf7)

---
updated-dependencies:
- dependency-name: googleapis/release-please-action
  dependency-version: 5.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <[email protected]>

* fix(permissions): match deny and ask rules past assignments and wrappers (#4281)

Claude Code matches a deny or ask rule past any leading assignment and past a
fixed set of wrappers — `timeout`, `time`, `nice`, `nohup`, `stdbuf`,
`command`, `builtin`, `noglob` and a bare `xargs` — so `Bash(git push *)` stops
`HUSKY=0 git push` and `timeout 30 git push` as well as `git push`. RTK's gate
matched the segment as written and nothing else, so every one of those forms
fell through to Default; under a host that treats Default as its own decision,
as #3909's OpenClaw does, they auto-apply.

The rewrite already looked through the same prefixes: `timeout 30 git push`
became `timeout 30 rtk git push` while the gate did not recognise `git push`
inside it. That was two readings of which command a line runs, and the gap
between them is this bug. The deny and ask loops now also try every reading
`matching_readings` gives: the rewrite's own peels — its env peel, its keyword
prefixes, its wrapper table — applied one prefix at a time, each assignment and
each `env` word a prefix of its own, each intermediate command kept. Where both
look through a prefix, the gate and the rewrite now read the same command
behind it, and a rule may name any layer: `HUSKY=0 git push *` stops
`env HUSKY=0 git push`, and `nohup HUSKY=0 git push` too, where denying is
stricter than the shell, since nohup looks for a program named `HUSKY=0`. The
readings start after the split's leftover grammar is gone, since
`{ HUSKY=0 git push; }` reaches the gate as `{ HUSKY=0 git push`. They are
computed once per segment, not once per rule, and stop after 32 prefixes; past
that the command behind them is unread, so where a deny or ask rule exists the
verdict is at least Ask and an allow rule on the outer wrapper cannot answer for
it. With no deny or ask rule, none of this is read.

Two wrappers the host strips were not in the rewrite's table, for a reason that
still holds: `stdbuf` exists to unbuffer the wrapped command's output, which
routing through rtk would buffer, and `xargs` runs whatever it is handed.
Recognising a command is not the same decision as rewriting it, so they sit in
a match-only table the rewrite never consults. `xargs` is peeled only in its
bare form, as the host does; any option makes the wrapper grammar give up.

The line is drawn at the shell. `env`, `exec` and `nocorrect` are peeled too,
though the host's list stops short of them; each runs the next word as the
command. A program's own arguments are not: `git -C . push` stays `git`, as the
host documents, since reading past it would mean knowing every program's option
grammar.

Allow is deliberately left as it was. The host matches allow past known-safe
variables only, and peeling a wrapper for it would let `timeout 30 <cmd>`
inherit `<cmd>`'s allow rule — so the peel can only make a verdict stricter.

The gate reads prefixes with the rewrite's own peels, so it stops where they
stop; there it reads the command as before, so no verdict is looser than it
was. A line setting more than 32 variables before its command now prompts
where a deny or ask rule exists.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* chore(deps): bump windows-sys from 0.59.0 to 0.61.2

Bumps [windows-sys](https://github.com/microsoft/windows-rs) from 0.59.0 to 0.61.2.
- [Release notes](https://github.com/microsoft/windows-rs/releases)
- [Commits](https://github.com/microsoft/windows-rs/commits)

---
updated-dependencies:
- dependency-name: windows-sys
  dependency-version: 0.61.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <[email protected]>

* chore(deps): bump which from 8.0.1 to 8.0.6

Bumps [which](https://github.com/harryfei/which-rs) from 8.0.1 to 8.0.6.
- [Release notes](https://github.com/harryfei/which-rs/releases)
- [Changelog](https://github.com/harryfei/which-rs/blob/master/CHANGELOG.md)
- [Commits](https://github.com/harryfei/which-rs/compare/8.0.1...8.0.6)

---
updated-dependencies:
- dependency-name: which
  dependency-version: 8.0.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>

* test(hook): probe the ls exit code, cover a clean git diff, isolate the python probe

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(git): no log cap notice under git's usage on git 2.56

git 2.56 answers `-h` with its usage and exit 0, where every earlier
release exited 129. `rtk git log` read "git refused the walk" from an
exit code of 128 or more, so on 2.56 `rtk git log -hp` ran the cap
probe after the usage text, the probe got the usage again, read it as
commits left over, and appended "[rtk] capped at 10 commits" under it.
`a_clustered_h_is_answered_by_git_once` fails this way on the macOS and
Windows runners that moved to git 2.56 (2.55 still passes).

A `-h` flag, alone or in a cluster, now counts as a walk that never ran.
Only a flag token does: `--grep -h` and `-- -h` still run the walk and
still get the notice.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* docs: drop hard-coded agent count in hooks/README.md

* docs: drop hard-coded command count in openclaw/README.md

* docs: drop hard-coded agent and mode counts in src/hooks/README.md

* docs: document the rtk bypass escape hatch in the README

* test(js): pin which coverage rows the cap keeps and what the hint stores

The cap had one capped fixture, and its single row below 100% never made
the structure compete for the budget. Four mutations passed on it:
skipping the structure pass, running the gap pass first, not counting the
rules as structure, and emitting the table reversed. A 30-file run with
every file below 100% is a 34-line table, and asserting it comes out as
bun's first 19 lines plus its closing rule turns all four red.

The hint was checked for presence only. A test build runs in recall
mode, so the branch reading the tee path never ran, and storing
hidden[1..] or offset 2 both passed while losing the first dropped row.
The test now follows the hint the way a caller does, through the entry
or the file, and compares with the rows the cap actually dropped.
Reading an entry back applies shown_upto, which is where recall starts,
so the offset is pinned too.

The at-cap test used a hand-trimmed table that bun would not print; it
is a real 16-file capture now, which bun prints as exactly 20 lines.

* fix(hermes): preserve legacy rewrites and other plugin directives

* test(cli): pin which invocations run the telemetry ping

maybe_ping is inert in any build without RTK_TELEMETRY_URL, test builds
included, so no test could tell whether a hook command pings. The
decision now lives in pings_telemetry, taken on the parse result, with
one call site; the tests cover parsed hook commands and the invocations
clap rejects (help, version, passthrough, a hook with a bad flag).

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(init): only offer the proxy when rtk-proxy can run, and remove its base URL on uninstall

* fix(json): print --keys-only schemas compactly

An indented schema can cost more than the minified input it replaces
(leak.json 31 -> 36 B, a 4-level chain 30 -> 79 B), which undercuts the
point of --keys-only now that it always prints the schema. Render it as
`{a:int,b:string,c:int}` with no indentation or newlines; the same
inputs are then at or under raw (31 -> 23 B, 30 -> 23 B), as are the two
real captures in tests/fixtures.

Only emit the key cap when keys are actually omitted: the old check ran
after pushing index 15, so a 16-key object printed "... +0 more keys".
It now shows 16 keys and appends "...+N" only when more remain.

The guard.rs paragraph drops the size argument for the same reason: the
schema is printed because it is the answer, and in compact form it is
also the smaller output.

* fix(hook): verify integrity for rewritten RTK commands

Port the seven missing operational-command entries and regression coverage
to current develop without importing unrelated commit history.

* fix(discover): route only dotnet build and restore to rtk dotnet

`rtk dotnet test` and `rtk dotnet format` change what the native command
does: format turns into a --verify-no-changes check, and test injects TRX
reporting into a temp results directory it deletes afterwards (breaking
MTP runs, --list-tests, and coverage/trx artifacts). Narrow the rule to
`build|restore` until #4602 and #4603 land, and assert that `dotnet test`
and `dotnet format` are not rewritten.

Also list `dotnet restore` in the .NET table of what-rtk-covers.md.

* fix(hooks): leave git unrewritten in Claude Code managed worktrees

Claude Code's worktree-isolation guard evaluates the command after the
PreToolUse hook has rewritten it, and it can only verify git when it is
spelled plainly. Every `rtk git …` the hook emitted was therefore refused
as "cannot be shown not to be git", so no git command could run from a
worktree-isolated session.

The payload carries no isolation flag, but managed worktrees always live
at `<repo>/.claude/worktrees/<name>`. When the payload cwd has that shape,
the Claude hook adds `git` to the exclude list so it passes through
untouched while the rest of the chain is still rewritten. Other hosts
pass no cwd and are unchanged.

Fixes #3864

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* test(hooks): pin the Claude Code worktree git exclusion

Pin the worktree exclusion: another git verb, a non-worktree .claude
directory, and the user's own exclude_commands kept alongside it. The
doc comment now says a worktree placed outside .claude/worktrees is not
detected, and the path check uses CLAUDE_DIR.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(dotnet): preserve test discovery output and requested reports

* fix(init): freeze legacy Antigravity texts and make migration best-effort

Addresses Round 1 review on #4335:
- Freeze the three historical release outputs (v0.36-v0.43, v0.44-v0.48, v0.49-v0.50) as literal constants and drop RTK_AWARENESS_HIGH, RTK_AWARENESS_DEFAULT, and live RTK_AWARENESS_FULL from the legacy matcher and test fixtures.
- Make legacy rules migration best-effort (warn on stderr and continue on I/O errors) and run it after writing plugin files on install.
- Pin CRLF exact match, CRLF suffix preservation, -g --uninstall workspace cleanup, exact vs suffix summary lines, and single-newline separator rejection with tests.

* fix(gain): exclude zero-input rows from exec time totals

`rtk gain` summed `exec_time_ms` over every tracked row. Rows recorded
through the passthrough/fallback path carry no input (`input_tokens = 0`)
and their duration is the wrapped process's whole lifetime, so a single
interactive TUI launched as `rtk claude` adds hours of wall-clock time.
On the reporter's install 131 such rows were 99.7% of `Total exec time`
and inflated the average 865x (9m5s reported vs 1.15s of real filtering).

Only rows with captured input now count toward the exec-time total and
its average, in the summary and in the daily/weekly/monthly breakdowns
(text, JSON and CSV), including the TOTAL row of the period tables.
Command counts, token totals and savings percentages are unchanged.

Closes #4003

Signed-off-by: Raymond Riter <[email protected]>
Co-Authored-By: Claude Opus 5 <[email protected]>

* chore(pytest): drop the two new comments

Signed-off-by: jaideeppyne <[email protected]>

* perf(python): compact repeated lint descriptions

* ci: increase test worker stack for debug parser

Give cargo test workers an 8 MiB stack so the large debug Clap parser builders can complete on Windows. The Windows test job aborted in Bun parser tests with STATUS_STACK_OVERFLOW.

* feat(git): fold `rtk git ls-tree --name-only` path lists by directory

`--name-only` and `--name-status` listings print one line per run of
paths sharing a directory (`src/cmds/git/ diff_cmd.rs gh_cmd.rs`), in
git's order, so every path rebuilds line for line. Paths with a space
or git's C-quoting stand alone in quotes. Every other form, a one-path
lookup, and a failed run pass git's bytes through unchanged.

The hook leaves a folded form unrewritten in front of `head`, `tail`
and `cat`, so those see git's lines. Measured 37.4% on rtk's own tree
(798 paths, 26,211 B to 16,398 B).

* chore(task): apply rustfmt to the api_key_command error

cargo fmt --check failed on the bail! added for an unrunnable
api_key_command, which is a gate the project requires before merge.
Formatting only, no behavior change.

* test(init): pin that remove_rtk_block stops at the first closing marker

A closing marker the user quotes after the RTK block must not extend the
removal over their prose.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* test(permissions): pin the load-failure warning and deny rules past a parse failure

The read-failure warning and the delegate exit code are checked end to end
through `rtk rewrite` with `RTK_REWRITE_HOST=openclaw`, for a settings file
that cannot be read and one that cannot be parsed. A unit test checks that
deny rules from the other settings files still apply after an unparseable
one, as they already did after an unreadable one. The new test helpers say
why they would panic.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* test(ruff): pin the analyze graph exemption on captured output

Replay real `ruff analyze graph` and `ruff rule E501` output (ruff 0.16.6)
through `rtk ruff`: the graph comes back whole and valid JSON, other
passthrough subcommands keep the cap. The unit tests now use the same
captured graph instead of a hand-built one, and pin which invocations
the classifier exempts.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(analytics): degrade unsupported weekly retry without losing report sections

* test(ng): pin workspace, table and process capture decisions

* fix(init): handle Windows CRLF suffix and guard against non-regular files

* fix(init): require the rtk-proxy binary for the on-ramp, gate binary_available, name the removed base URL

* fix(hooks): preserve Claude status semantics and refine agent hook probes

- Restore develop's Claude hook status behavior so legacy script settings remain subject to version checks and deleted scripts report as missing.
- Remove Cursor outdated detection and parallel outdated checks to keep warning formatting pure.
- Drop unregistered script fallbacks in Gemini and Cursor to avoid false detections from orphaned files.
- Simplify agent probe definitions and eliminate redundant byte order mark handling across agent configs.
- Add hermetic in-process test isolation suites to verify all agent probes and warning suppression paths across platforms.
- Pin all Claude status states, agent probe branches, and full install and uninstall cycles across gain and daily check entrypoints.

* docs(pipelines): name the shell api_key_command runs under

api_key_command now goes through the platform default shell rather than
a hardcoded sh, which on Windows means cmd. A key command written for
Git Bash silently stops working there unless it is rewritten or wrapped
in an explicit `bash -c`, so the upstream table says which shell runs
it, as asked in the review of #4555.

Signed-off-by: Vinícius DS Andrade <[email protected]>

* test(task): hard-code the transcript slug the test expects

Deriving the expected slug with the same map the code applies proves
only that the two agree, not that either is right. A fixed path with a
`.` and, on Windows, a `:` in it pins what the slug rule does to each
character, and the fixed path has no `to_str().unwrap()` to need.

Signed-off-by: Vinícius DS Andrade <[email protected]>

* test(task): run a .cmd wrapper on PATH through run_for_output

`Command::new("npm")` cannot start the `.cmd` wrapper npm installs as
on Windows, which is the failure `resolved_command` fixes in
run_for_output, and nothing exercised it. The test writes a hello.cmd
into a temporary directory and asserts a bare `hello` runs and its
output is captured.

`which` reads the process's own PATH, and the suite never changes the
process environment under the tests running beside it (set_var is also
unsafe on edition 2024 and the crate denies unsafe_code), so the test
runs the test binary again with the directory in front of the child's
PATH, filtered to its own name with --exact, and checks the re-run
reported the one test so a mismatched name cannot pass vacuously. The
two direct environment reads this takes are registered in
DIRECT_READS, beside the one utils.rs already has for the same PATH.

Signed-off-by: Vinícius DS Andrade <[email protected]>

* test(isolation): keep proto and volta roots for a child's shims

A proto or volta shim on PATH finds its toolchain through PROTO_HOME or
VOLTA_HOME, falling back to a directory under HOME. The pinned scratch home
holds none, so on a machine using either manager every isolated go, node,
npm or pnpm printed the manager's error instead: the go -json help test
failed, and the wrapped-help comparisons passed on that error against
itself. Keep both roots, as RUSTUP_HOME is kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* test(recall): pin the 16-char key and old hints after an upgrade

The collision test now asserts both stored keys, so a key length other
than 16 fails it. A new test stores an output under a 12-char key, as the
previous release did, stores it again under the 16-char key, and checks
that each hint returns its own row instead of an ambiguous-prefix error.

The getting-started guide's example hint uses the 16-char form.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(hooks): preserve host checks when settings cannot be loaded

* fix(lint): route SQLFluff file shortcuts through lint

* fix(json): quote --keys-only schema keys that read as syntax

The compact schema prints keys bare between `,` `:` and braces, so a key
containing one of them changed the structure the schema shows:
{"a":{"b:int,c":1}} and {"a":{"b":1,"c":1}} both printed
{a:{b:int,c:int}}. Such keys, and keys with whitespace, control
characters or a leading `...`, are now printed JSON-quoted.

Adds a test for inputs where never_worse would print the raw JSON:
with the compact schema smaller than every earlier test input, routing
--keys-only back through the guard no longer failed any test. Also
states that short string values can make the schema a few bytes larger
than the input, and replaces two unwrap() calls in tests.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* test(cargo): pin nextest combined annotation after passed

A run with retries prints `2 passed (1 slow, 1 flaky), 1 failed, 4 skipped`.
The annotation group accepts any parenthesised text, but the tests only
exercised `(1 slow)`, so narrowing it to `\(\d+ slow\)` passed the suite
while turning this run green. Pin it with a real cargo-nextest 0.9.143
capture.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* docs(guide): drop rows for commands the hook does not rewrite

The page lists what the hook intercepts. `cargo nextest`, `dotnet test`,
`dotnet format` and `docker compose up` were listed, yet `rtk rewrite`
leaves all four alone. `cargo nextest` and `docker compose up` have no
rule yet (#2046, #1727); `dotnet test` and `dotnet format` stay out of
the `dotnet` rule until their handlers match the native commands
(#4603 and #1289, #4602).

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* test(python): pin tie order and truncation of shared lint descriptions

Ruff now orders tied files and per-file rules by name, and both filters
truncate a long shared description in Top rules to 100 characters.
Neither was covered: a count-only sort or an untruncated row passed the
suite. Add real Ruff 0.16.6 and SQLFluff 4.3.0 captures that reach both.

Drop the README claim that Ruff's recovery log keeps full messages: its
lines are truncated like the detail lines, and recovery depends on the
configured mode.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* feat(hooks): wrap unambiguously-fish scripts as rtk run --shell fish

A host can evaluate the Bash tool's command string with a POSIX/zsh layer
while the user's shell is fish, so a fish script (`if … end`, `; and`, `; or`)
fails to parse before RTK is ever consulted. Nothing downstream can recover
from that: the command is already gone by the time a hook would see the error.

Classify the command instead. A script is *provably* fish when a fish-only
keyword (`end`, `begin`, `switch`, `and`, `or`, `not`) sits at a command
boundary and nothing a POSIX shell needs appears anywhere — `then`, `fi`,
`do`, `done`, `esac`, `elif`, `[[`, a heredoc, or a backtick, none of which
fish has. Such a script is handed back as

    rtk run --shell fish -c '<script>'

one quoted argument every host layer parses as a single command, with the
script byte-identical inside it.

The classifier is this module's own; it does not ask the shared lexer to treat
fish keywords as unattestable, because that lexer reads the host's command
string as bash.

Gates, in order, with the pure ones first so a non-fish command reaching this
path does no I/O: not Windows, not already delegating (`rtk …` or an explicit
`-c` wrapper), classified fish, no `\\` or `\'` — the only two sequences where
fish single-quote semantics diverge from POSIX, so every wrapped script
round-trips byte-identically under sh/bash/zsh *and* fish — then
`hooks.wrap_fish_scripts` (default on) and a resolvable `fish` binary.

The wrap is never auto-allowed: its strongest verdict is `Ask`, because the
script's content is not attested. A deny rule still outranks it.

* docs: document automatic fish script wrapping

README auto-rewrite section, the hooks permission-model README, and the
French FEATURES guide (behaviour, gates, the `wrap_fish_scripts` opt-out and
the config sample).

* fix(hooks): classify only code, keep the rewrite, refuse what the gate refuses

Round-1 review of #4127, three defects, one mechanism each.

**Comment text was read as code.** Neither the classifier nor the shared lexer
models `#`, so a separator inside a trailing comment reset command position and
the next English word was matched against the fish markers:
`echo $((1+2))  # x; and y` is a working bash command that was wrapped and then
failed under fish with nothing run at all. Every shell in play agrees where a
comment starts and ends, so the span is dropped before anything is classified.
A `#` inside quotes is still an argument, and a fish script may carry a comment
of its own.

**The wrap cost the rewrite.** It preceded `rewrite_command`, so a fish chain
whose leading command was rewritable lost the savings `develop` delivers —
`git diff HEAD~3 HEAD; and true` went from 76.9% to 0%. The script's own
commands are rewritten first and the wrap carries the result, honouring
`exclude_commands` and `transparent_prefixes` like any other rewrite.

**It was the only path emitting a command RTK could not attest.** It now
refuses everything the unattestable gate refuses — command and process
substitution, file-target redirects — plus fish's own `(cmd)` substitution,
which the bash-shaped lexer reads as a subshell and lets past. What still
reaches the wrap is a script whose only unattestable property is fish control
flow, which is the case the feature exists for, and `src/hooks/README.md` now
records why that one is exempt, what bounds it, and that Codex renders `Ask` as
a protocol-level `allow`.

Also from the review: `tests/fish_wrap_test.rs` asserts on the fish-absent
branch instead of returning early, so the file is no longer inert on a runner
without `fish`, and `decide`'s gate-order doc lists the wrap.

* fix(hooks): gate on the stripped code, and end a comment at an operator

Round-2 review of #4127.

**A quote in a comment blinded the gates** (new in the previous push). The
classification moved to the comment-stripped text but the three gates still
read the raw script, and an apostrophe there opens the shared lexer's quote
state — everything after it collapses into one argument, so no redirect and no
substitution was ever seen while the classifier saw clean fish:

    test -d src; and git status # don't
    cat secrets.env > /tmp/leak

wrapped, and running the emitted form wrote the file out. The gates now read
the same stripped code the classification does, so the second line is seen and
the script defers, as it does on `develop`.

**A comment glued to an operator was still read as code.** `strip_comments`
started a word only after a space, tab or newline, so `;#`, `|#` and `&#` kept
round-1's defect: `echo ${HOME:-x};# note; and more` runs in bash and dies
under fish with nothing executed. Both shells start a word after an unquoted
operator; so does this now.

**The suite was red on any runner without `fish`.** The no-fish branch of
`rewrite_keeps_the_inner_rewrite_inside_the_wrap` asserted a defer, but the
script's leading command is rewritable on its own, so the answer there is the
ordinary rewrite — asserting that exact string pins the wrap as genuinely off
rather than merely absent. `rewrite_defers_fish_script_with_divergent_backslash`
no longer returns early either.

CI installs `fish` on the Linux and macOS test runners, since without it the
wrap never fires and the feature's only code path is never exercised; Windows
has none to exercise, where the wrap is disabled by design. The fixtures no
longer name `/etc/passwd`, which the repo's own semgrep rule flags.

Docs: the script no longer travels byte-identical — its commands are rewritten
first — and `README.md`, `src/hooks/README.md` and the French FEATURES guide
now say so, along with the substitution and file-redirect refusals that make
`for f in (ls) … end` defer.

* fix(hooks): refuse where the shells disagree about a comment

Round-3 review of #4127.

`&` is the one boundary character bash and fish read differently. Since fish
3.0 `&` is job control only at the end of a job, so a `#` glued to it opens a
comment in bash and continues the word in fish:

    bash -c 'echo A&#y'   ->  A        (comment)
    fish -c 'echo A&#y'   ->  A&#y     (one literal word)

`strip_comments` took bash's reading, which hid everything after the `#` from
the gates while the emitted wrap carried it and fish ran it —
`test -d src; and echo HIT&#y > /tmp/LEAK` wrapped and wrote the file. Taking
fish's reading instead re-admits comment text as code, which is the defect
round 1 fixed. Neither is safe, so the scanner refuses: `strip_comments`
returns `None` when a comment start is glued to a lone unquoted `&`, and the
script keeps the host's own behaviour. `&&#` and `||#` still strip — both
shells agree there — and the run is counted only over syntactic ampersands, so
an escaped `\&` before a lone `&` refuses too, and a run never crosses the
newline that closes a comment.

* fix(hooks): keep the fish wrap off wherever a deny rule could match

Round-4 review of #4127. The round-3 answer peeled `and`/`or`/`not` off the
front of a segment, which was both too wide — it changed how every command is
matched against deny rules, not only fish ones — and too narrow: it only ever
looked at the start of a segment. A denied command in the condition of an `if`
or a `while` went straight through, and the wrap handed it to a fish that runs
it where bash refuses the syntax outright:

    deny: ["Bash(rm:*)"]
    if not rm -rf victim\n  echo x\nend      wrapped, and fish deleted the file
    while rm -rf victim\n  break\nend        the same

Peeling keywords chases one placement at a time, and fish starts commands in
more places than a keyword list holds. So the criterion is no longer "peel the
keyword" but **never wrap a script a deny rule could match anywhere**: every
word is treated as a possible command start and the permission gate's own
`command_matches_pattern` answers for each run of words
(`permissions::deny_matches_any_word_run`). The words come from the same
comment-stripped code the classification and the gates read, dequoted through
the shared `lexer::shell_split`, so an apostrophe in a comment cannot hide
them and `'rm'`, `r'm'` and `"rm"` all read as `rm`.

The rules are the host's own, the ones its verdict was judged against:
`decide` takes them, `decide_hook_action` loads them once per host instead of
twice, and `AgentPath` supplies the set its own `verdict` reads.
`check_command` goes with that — `rewrite_cmd` was its only caller and now
loads Claude's rules explicitly, which is what it documented already.

The keyword peel leaves this PR entirely; with the wrap deciding for itself,
nothing here needs it.

Conservative by construction: a denied program named as a plain argument keeps
the wrap off too. That costs a rewrite, never a guarantee. With no deny rules
nothing changes, and a rule naming something else leaves the wrap alone.
`deny_keeps_the_wrap_off_in_every_placement` pins all twelve spellings through
`rtk rewrite` under a real `settings.json`, asserting *never wrapped* rather
than a particular exit code.

* fix(hooks): make the fish wrap opt-in, and ask for it explicitly

Round-8 review. Claude Code does not take the hook's answer as final for a
rewrite: with `updatedInput` and no `permissionDecision`, it runs its own
permission check on what came back. For a wrap, what it checks is one
`rtk run --shell fish -c '…'` command, with the script's own commands invisible
to every rule it has. Measured against Claude Code 2.1.305: an
`allow: ["Bash(rtk:*)"]` ran the whole script unprompted, and a deny from
`--settings`, from `--disallowedTools`, or from a project the agent had `cd`'d
away from stopped applying.

Two holes, and RTK can close only one of them. The gate reads deny entries, so
an allow rule matching `rtk` and an ask rule naming a script command were never
considered — both are closed by the hook stating its own decision. The second
is the rule sources that never reach a hook at all; nothing here can see them.

So the wrap becomes a thing a user asks for, and says so when it happens:

- `hooks.wrap_fish_scripts` defaults to `false`. Without it the Claude hook
  answers exactly as `develop` does.
- Every wrap the hook emits carries `"permissionDecision": "ask"`. An ordinary
  rewrite is untouched: an allow rule still renders as `allow`.
- The user docs say what turning it on costs — the script reaches the prompt as
  one `rtk run` command, and a rule RTK cannot read becomes that prompt rather
  than a refusal.

The deny-entry and read/parse attestation stays as it was: with the flag on, a
rule RTK *can* see still keeps the wrap off entirely.

Six decisions this branch added still passed the suite when flipped, each
changing real output. `only_the_claude_hook_wraps` now drives Codex, Vibe,
Trae, the VS Code hook and the Copilot CLI through their own payload shapes —
the Codex row sent `"tool_name":"shell"`, which that path ignores, so it passed
whatever Codex decided — and each row carries a control proving the payload
reaches the decision at all. `the_pipeline_swap_gate_never_sees_a_wrap` drives
a real swap rule, with the swap itself as the control.

Rebased onto `develop`, whose loader now fails closed: a settings file that
exists but cannot be read or parsed warns and pushes `ask *`. Both behaviours
are kept in `load_permission_rules_tracking`, with `every_file_read` cleared
beside each push, and `load_permission_rules_from(paths)` kept as the wrapper
`develop`'s tests call. One behaviour moves with it, correctly: an empty
settings file counts as read, because `develop` skips it — it holds no rule.

Also from the review's optional list: the attestation is computed only for a
command that classifies as fish, so an ordinary command no longer walks the
settings twice, and four doc passages that described paths this head cannot
take are corrected.

* fix(hermes): preserve earlier hook command directives

* fix(git): preserve ls-tree pipeline lines with global options

* fix(dotnet): retain solution test failure details

* fix(hook): honour opt-outs inside a quoted script behind uv run

A routable wrapper falls through to its own rule when the inner rewrite
is dropped, so `uv run bash -c 'pytest'` under exclude_commands, or
`uv run bash -c 'RTK_DISABLED=1 git status'`, became
`rtk uv run bash -c '…'` and the opted-out command was filtered anyway.

Refuse the fall-through when the wrapped quoted script holds an excluded
command or an RTK_DISABLED= prefix.

Fixes #4704

Signed-off-by: ShryukGrandhi <[email protected]>

* fix(read): separate line numbers with a tab

`--line-numbers` formatted each prefix as `{:>width$} │ `, and the
box-drawing bar is three bytes in UTF-8. That made `rtk read -n` about
4% larger than `cat -n` on a minified source file — the only negative
case in scripts/benchmark.sh, so the benchmark currently fails against
develop's tree.

Use a tab instead, keeping the width alignment. The output now matches
`cat -n`'s shape and is about 4% smaller than it (50,360 -> 47,947
tokens on src/main.rs), so the benchmark case turns from negative to
good.

Update the two line-number expectations in read_window_bytes_test.

* chore(deps): bump getrandom from 0.4.2 to 0.4.3

Bumps [getrandom](https://github.com/rust-random/getrandom) from 0.4.2 to 0.4.3.
- [Changelog](https://github.com/rust-random/getrandom/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rust-random/getrandom/compare/v0.4.2...v0.4.3)

---
updated-dependencies:
- dependency-name: getrandom
  dependency-version: 0.4.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>

* chore(deps): bump serde_json from 1.0.149 to 1.0.154

Bumps [serde_json](https://github.com/serde-rs/json) from 1.0.149 to 1.0.154.
- [Release notes](https://github.com/serde-rs/json/releases)
- [Commits](https://github.com/serde-rs/json/compare/v1.0.149...v1.0.154)

---
updated-dependencies:
- dependency-name: serde_json
  dependency-version: 1.0.154
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>

* chore(deps): bump actions/create-github-app-token from 3.2.0 to 3.3.0

Bumps [actions/create-github-app-token](https://github.com/actions/create-github-app-token) from 3.2.0 to 3.3.0.
- [Release notes](https://github.com/actions/create-github-app-token/releases)
- [Changelog](https://github.com/actions/create-github-app-token/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/create-github-app-token/compare/bcd2ba49218906704ab6c1aa796996da409d3eb1...64ab729a6294000f5aa4a4bc0b73d1f32bc45510)

---
updated-dependencies:
- dependency-name: actions/create-github-app-token
  dependency-version: 3.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <[email protected]>

* chore(deps): bump crossterm from 0.28.1 to 0.29.0

Bumps [crossterm](https://github.com/crossterm-rs/crossterm) from 0.28.1 to 0.29.0.
- [Release notes](https://github.com/crossterm-rs/crossterm/releases)
- [Changelog](https://github.com/crossterm-rs/crossterm/blob/master/CHANGELOG.md)
- [Commits](https://github.com/crossterm-rs/crossterm/commits/0.29)

---
updated-dependencies:
- dependency-name: crossterm
  dependency-version: 0.29.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <[email protected]>

* fix(benchmark): point read cases at src/lib.rs

* test(read): pin multi-digit line-number alignment

A 12-line input pins both the tab separator and the right-aligned width. Reverting either the tab or the width padding fails the test.

* fix(pytest): keep the E lines of error and failure blocks

* fix(pytest): keep pytest's default report with --tb=no

* fix(cargo): keep nextest timeouts and retried failures

The nextest summary regex had no slot for 'N exec failed' or 'N timed out', so a run whose only failure was a timeout took the green branch, and failure blocks only opened on a bare FAIL line, so TIMEOUT and the 'TRY n FAIL' lines of retried tests lost their details. Fixes #4694.

* fix(pytest): keep the failure location and xfail lines, close blocks at section banners

* fix(pytest): mark the E lines cut from a block

* fix(ruff): restore a leading -- before routing

clap's trailing_var_arg strips a `--` that leads `args`, so `rtk ruff -- check`
reached run() as `["check"]`, was routed as the check subcommand with no path,
and linted the whole working directory. Restore it the way golangci and dotnet
do, so the line reaches ruff as typed.

Fixes #4702

* fix(pytest): end the failure section at the warnings summary

* fix(init): return exit code 2 when codex uninstall guard refuses hook (fixes #4164)

* fix(pytest): keep the test frame, read clustered flags with the tokenizer

* fix(init): install the OpenCode plugin independently of agent setup

`rtk init -g --opencode` ran the plugin install after fallible Claude
writes, so a malformed CLAUDE.md block or a read-only ~/.claude exited 1
with no plugin. Hoist the OpenCode install into run(), attempt both
targets, and report each failure independently; the plugin path is only
reported when one is actually in place.

Fixes #4413

* fix(pytest): read shortened block headers, never take source or locals lines as frames

* fix(pytest): match pytest's block separator exactly, never take locals as frames

* docs: align what-rtk-covers.md with hook rewrites and add drift test (fixes #4695)

* test(pytest): pin that a `=====` diff line stays an E line

`is_error_line` leaves out an `E ` line whose text, after its leading
spaces, starts with `= `, so that a local named `E` under `-l` is not
read as an error line. An assertion diff of a reStructuredText heading
produces `E       =====`, which must stay an E line: shown in the window
and counted toward the elision marker. Real pytest 9.1.1 capture.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* test(init): pin the Antigravity legacy-path guard and rtk-prefix check

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* test(hooks): pin foreign hooks and the leftover Claude script

A non-rtk BeforeTool hook in ~/.gemini/settings.json or a non-rtk entry
in ~/.vibe/hooks.toml must not count as an rtk install. A registered
`rtk hook claude` next to a leftover rtk-rewrite.sh reports Outdated.

Also bring the warn_hook_issues doc, the hooks README note on
maybe_warn() and an OpenCode test comment in line with the code.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* test(hooks): run the wrapped-script case, and isolate both fish tests

`wrapped_multiline_fish_script_executes_and_propagates_exit_code` was
`#[ignore]`d, so the one case that executes a wrapped script through
`rtk run --shell fish` never ran: `cargo test --all` skips ignored tests, and
CI now installs `fish` on Linux and macOS. Enable it, and assert on both
branches the way the tests above it do: without a resolvable `fish` the
command must report exit 127 rather than appear to have run.

The direct `sh`/`zsh`/`fish -c` calls in both tests now go through
`common::native_command`, so they get the same isolated home as the rtk child:
`fish -c` sources the user's `config.fish`, and anything it prints landed on
the stdout `wrapped_quoting_round_trips_under_posix_and_fish_hosts` compares
byte for byte, failing the test for any fish user whose config greets them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(hooks): ask for a wrap only when RTK built it, and attest only where one can be

The Claude hook decided a wrap's explicit `ask` by the shape of the rewritten
string alone. So an `rtk run --shell fish -c '…' && git status` the agent typed
itself, which the rules only rewrote around, went from `allow` to `ask`. That
happened even with `hooks.wrap_fish_scripts` off. The explicit ask now needs the
flag on, and a command that did not already start in the wrap form. `try_wrap`
never wraps a command whose first word is `rtk`, so such a command is one the
agent typed: it keeps its ordinary decision. With the flag off, no wrap exists,
and the hook answers exactly as it does without the feature.

The deny-rule attestation walks the settings a second time. It now runs only
where a wrap is possible: the flag is on and the command is provably fish. One
gate, `claude_hook_deny_rules_for`, serves both the hook and
`rtk hook check --agent claude`, so the two read the settings alike. Before,
with the flag off, both walked the settings twice for a fish command and
repeated every loader warning. With the flag on, `rtk hook check` did the same
for every command.

Several comments still said Claude Code takes RTK's decision as final, or that
the files RTK reads are all of its rules. They now say what holds: RTK reads
those files, and the hook asks explicitly for every wrap it builds.
`hooks.wrap_fish_scripts` turns the wrap on, not off.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* refactor(analytics): let the ccusage row aggregation name its granularity

`aggregate_weekly_periods` sums the rows legacy ccusage emits per project
for one week. Monthly reports need the same sum (#4761), so rename it to
`aggregate_periods` and pass the granularity its overflow errors name.
Weekly errors read as before, and weekly behavior does not change.

* fix(analytics): sum per-project ccusage rows in monthly economics

With per-project grouping on (`"groupByProject": true` in its config),
ccusage 18 emits one monthly row per project, and no row carries the
project. `merge_monthly` calls `set_ccusage` once per row on the same
map entry, so each row overwrote the one before it:
`rtk cc-economics --monthly`, the default summary and the JSON `totals`
kept only the last project's spending, and the cost per token derived
from it.

Sum the monthly rows by month with `aggregate_periods`, as the weekly
report has done since #4409. Daily rows are left as they are: ccusage 18
emits one row per day there.

* test(analytics): pin summed per-project rows in monthly economics

Run `rtk cc-economics --monthly --format json` against a ccusage stub
that repeats one month with the two project rows of the weekly
duplicate test. The monthly row and `totals`, which the default summary
prints, must carry the sum of both rows joined with the tracker's
savings. Before the fix they carried the second row only.

* fix(hook): inspect nested quoted shells for opt-outs behind uv run

Signed-off-by: ShryukGrandhi <[email protected]>

* fix(permissions): ignore NotADirectory and strip BOM before empty check

Signed-off-by: Samran Asif <[email protected]>

* fix(hook): preserve fish-wrap decisions and reuse permission scans

* fix(ruff): route flag-first implicit check invocations to check

* fix(ruff): tokenize global options before routing subcommands

Finding the first token without a `-` misrouted invocations that put a
global option before the subcommand: `--config ruff.toml` fed its value
to the routing decision, so `--config ruff.toml format --check` was
treated as an implicit check and rebuilt as one, and an explicit
`check` after `--isolated` was forwarded as a path.

Route on a real pre-subcommand scan instead. Only `--config` and
`--color` consume a value (ruff 0.17 `--help`); every other global
token is a flag, and the first bare token decides the route — a known
subcommand routes, anything else is a path and the run is an implicit
check. An explicit `check` is consumed at its actual position, and
flag-only runs keep the top-level requests (`--version`, `--help`)
while others stay implicit checks. `is_format` now keys on the
effective subcommand, so `help format` no longer reaches the format
filter.

The boolean-predicate tests are replaced by complete CLI cases that
pin the full forwarded argument vector, which is what let the
forwarding regressions slip through before.

* fix(ruff): tokenize with arg_tokenizer and keep help output complete

Route through the shared `arg_tokenizer` (rust-patterns rule 6) instead
of the hand-rolled scan: `tokenize_grammar` with a `Dialect::Posix`
grammar and a `ruff_takes_value` predicate for the two value-taking
global options, so an option value is consumed rather than read as the
subcommand or a path. The first free positional before the `--`
boundary decides the route, and a path after the boundary keeps the
run an implicit check.

Help and version output regressed to the bounded passthrough once the
format filter keyed on the effective subcommand. Extract the output
choice into `filter_ruff_output` and keep `help`/`version` requests and
top-level flags complete — raw Ruff returns every byte, and so does
rtk — while other subcommands keep the bounded passthrough. Pin the
stdout bytes for the complete routes instead of only the forwarded
argument vector.

* fix(ruff): restore the UTF-8 ellipsis in the cap notice

A Windows-1252 round-trip mangled the ellipsis in the '+N more' cap
line and in its test expectation to the mojibake byte sequence.
Restore the proper U+2026 in both.

* fix(ruff): treat check options before positionals as implicit checks

A check-only option's value looked like a free positional to the router,
so `--output-file check keep.py` removed that value as the explicit
subcommand and `--output-file` consumed `keep.py`.

Route the whole invocation as an implicit check whenever an option
outside ruff's global grammar appears before the first free positional:
nothing is removed and no later word is treated as the subcommand. Read
help and version from tokenized flags so a cluster such as `-qh` stays
top-level, and drop the redundant post-loop `--` fallback.

Add plan pins for every router decision named in the review.

* fix(read): book the span that was shown, and test the booked row

With --max-lines the tracking baseline was smart_truncate(raw, N) while
the printed output was never_worse(raw, smart_truncate(filtered, N)): the
two sides truncated different text. A small file printed as cat would
booked a saving, a filter whose result fit the window lost its saving,
and a row could go negative.

The baseline now follows what was printed: when never_worse fell back to
the raw content, or when the window removed nothing from the filtered
content, the baseline is the whole raw content; otherwise it is the raw
content cut to the same window, never smaller than what was shown.

tests/read_tracking_baseline_test.rs runs the binary with an isolated
RTK_DB_PATH and reads the row it booked, for the file and stdin paths,
--head-lines, --tail-lines, --max-lines, -n and a filtered window. Three
of its cases fail on the previous code, one of them with a negative row.
The ignored unit test that spawned the binary moves there with a
positive assertion on the printed code line.

* fix(ls): hold a bare listing to what ls prints, not to the long listing rtk asks for

rtk ls asks its child for a long listing to learn each entry's type and size,
and printed that size after every file even when the user never passed -l.
Without -l, ls prints bare names, so the size column was pure addition: in
rtk's own repository root `ls` prints 341 bytes and `rtk ls` printed 531. The
never-worse guard could not catch it. The runner compares the filtered output
with the child's stdout, and that stdout is the long listing rtk asked for
(2067 bytes), not the listing the user did.

A listing without -l now prints names only, and is held to the output of `ls`
run with the user's own arguments: the compact form is shown only when the
shared never_worse finds it cheaper than that output. The same baseline
governs the two places the filter used to print the long listing verbatim: a
listing it cannot parse (-T and -D dates, the attribute lines of -@ and -e),
and a failed run, where `ls src missing` printed 742 bytes of long listing for
the 108 bytes ls printed. A long listing keeps its existing filter and baseline.

The baseline costs one more `ls` process for a listing without -l.

* chore(deps): bump ignore from 0.4.32 to 0.4.33

Bumps [ignore](https://github.com/BurntSushi/ripgrep) from 0.4.32 to 0.4.33.
- [Release notes](https://github.com/BurntSushi/ripgrep/releases)
- [Changelog](https://github.com/BurntSushi/ripgrep/blob/master/CHANGELOG.md)
- [Commits](https://github.com/BurntSushi/ripgrep/compare/ignore-0.4.32...ignore-0.4.33)

---
updated-dependencies:
- dependency-name: ignore
  dependency-version: 0.4.33
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>

* chore(deps): bump clap from 4.5.60 to 4.6.7

Bumps [clap](https://github.com/clap-rs/clap) from 4.5.60 to 4.6.7.
- [Release notes](https://github.com/clap-rs/clap/releases)
- [Changelog](https://github.com/clap-rs/clap/blob/main/CHANGELOG.md)
- [Commits](https://github.com/clap-rs/clap/compare/clap_complete-v4.5.60...clap_complete-v4.6.7)

---
updated-dependencies:
- dependency-name: clap
  dependency-version: 4.6.7
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <[email protected]>

* test(read): pin the stdin baseline when the window cuts nothing

run_stdin computes window_cut on its own; only the file path had a test
for a filtered window that removed nothing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* refactor(discover): one segmenter behind the permission and classify walkers

`split_for_permissions` and `split_on_operators` each walked the token stream
themselves. The differences between them were real and deliberate — the gate
must break on everything a command could hide behind, classification must not
pretend to — but they were emergent, spelled out only in a comparison table
in a doc comment and re-derived in two places.

Both now call one `segment(cmd, Policy)`. The differences become a `Policy`:
which newline mode, whether `&`/`(`/`)` end a segment, whether redirects are
kept or excised, whether to stop at the first pipe. What was a table of
accidents is now a pair of constants.

`Segment` carries the byte range each command occupies as well as its text,
which the rewrite needs to splice around a command rather than rebuild the
line from tokens.

Behaviour is unchanged, and that is the point, so it is asserted rather than
claimed: both walkers are kept under `#[cfg(test)]` and a generated corpus of
over five thousand compound commands — every command shape crossed with every
separator, wrapper and redirect form — must segment identically through the
old walker and the new policy. Mutating the new segmenter (dropping `)` as a
boundary, ignoring `stop_at_pipe`) fails that test on a named input.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

* fix(hook): rewrite commands without rewriting the text between them

`rewrite_compound` split a compound command on each operator, rewrote the
segments, and then rebuilt the line by emitting `" {op} "` around every
operator it had split on. The operators came back, but the spacing was the
emitter's rather than the author's, and anything it did not model was
invented: `;;` came back as `; ;` and `;&` as `; &`, which is how a `case`
statement became a syntax error and a fall-through became a background job.
Those two had to be fixed in the lexer because the emitter could not tell
them apart from two separate operators.

The emitter now copies the gaps instead of rebuilding them. A segment is
emitted as its own leading whitespace, its rewrite, and its own trailing
whitespace; the operator between two segments is copied byte for byte from
the source. The multi-line path keeps a line's trailing whitespace as well as
its indent.

Wrapper prefixes had it too: `strip_word_prefix` trims the remainder so the
inner command can be matched, and the three sites that rejoined it used a
single space, so `uv run  ls` and `timeout  5  cargo build` came back
respaced. `prefix_gap` hands the original back instead.

The pipeline rewriters had the same habit one level down, joining producer,
`|` and consumer with `format!("{} {}", ..)` over trimmed text, so
`ls -la  |  head -3` came back single-spaced around the pipe. They now carry
the gaps either side of the `|` through as well. That was not a regression —
it predates this commit — but it is the same defect, and leaving it would
have made this commit's claim false for every pipeline.

What changes, all of it the author's formatting coming back intact:

    ls -la  &&  ls     ->  rtk ls -la  &&  rtk ls      (was ` && `)
    ls -la  |  head -3 ->  rtk ls -la  |  head -3       (was ` | `)
    uv run  ls -la     ->  uv run  rtk ls -la           (was `uv run `)
    ls ; ls            ->  rtk ls ; rtk ls             (was `; `)
    ls;ls              ->  rtk ls;rtk ls               (was `; `)
    ls &&\nls          ->  rtk ls &&\nrtk ls           (was ` && `)
    case … 1;;esac     ->  … 1;;esac                   (was `;; `)

Five tests pinned the old normalisation and now pin the input's own spacing.
Each rewritten form was checked with `bash -n`.

The property is what makes this checkable rather than claimed: for a
generated corpus, deleting every inserted `rtk ` from the output must give
back the input. It fails on a named input if the emitter is put back the way
it was. It also caught a case no example covered — a line's trailing
whitespace was dropped before a newline — which is fixed here rather than
excused in the assertion. Its one exemption is the whole-command `trim()`
that happens before the emitter is reached.

Its first version asserted nothing about pipelines: none of the commands it
generated is a safe pipe consumer, so every pipe case returned `None`. The
corpus now crosses real producers and consumers with irregular spacing around
the `|`, which is what surfaced the pipeline rewriters above.

Separators are safe to reproduce verbatim: `;`, `&` and `|` each end a word
on their own, so a rewrite ending in a digit cannot fuse with them. A
redirect can — `--head-lines 1` beside `>out` reads as an fd-1 redirect — but
that is settled before the emitter by `join_redirect_suffix`, and both cases
already had tests.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

* fix(ls): hold long listings to what ls prints too, and never turn a rejected command into an empty success

A listing with -l is now held, like a bare one, to the output of ls run
with the user's own arguments. When ls rejects the command line outright
(non-zero exit, nothing on stdout) while rtk's listing succeeds, there is
no listing to hold to, so rtk lists as before instead of printing nothing
with exit 0.

* test(ls): keep bare -h on a listing rtk compacts, and check the passthrough on its own

a_tools_own_dash_h_is_still_filtered runs `ls -h` again, on a directory of
60 files where the compact form wins, so it still proves rtk filters a
listing that carries -h. The two-file directory, where rtk now prints
exactly what `ls -h` prints, gets its own test.

* test(init): pin the uninstall exit codes outside the refused-hook cases

An uninstall that fails for another reason exits 1, and every other
agent's completed uninstall exits 0. The codex test now also checks
that AGENTS.md is cleaned when the hook is refused.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* test(dotnet): pin reported TRX aggregation and make its solution test deterministic

The solution-root test wrote its reports within one clock tick of the run
start, so the since filter could drop them; it now waits first, asserts the
summed counts and gives the fallback report a failure that would show if it
won. Zero-test reports still count as a project, and the user-directory case
without a logger is kept beside the one with it. Results File paths are
printed unquoted, so the quote trim goes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* test(discover): pin the ls-tree pipeline guard's whitespace and subcommand checks

A double space after `git` still reaches the git rule with `-C`/`-c`, and
other git subcommands taking `--name-only` keep their pipeline rewrite.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* docs(root): add backlink to pro

* test(init): pin the OpenCode gate and the both-failure report

The OpenCode install now runs before the agent setup and reports each
failure. Two decisions it adds had no test: that no plugin is written
without --opencode, and that a double failure reports both causes. Each
test fails on a mutation of its decision. The double-failure test skips
where the filesystem ignores directory modes (root).

Co-Authored-By: Claude Haiku 5.5 <[email protected]>

* test(sqlfluff): pin a long option named like a subcommand

A long option whose name matches a SQLFluff subcommand (--format) must not
be read as that subcommand. Without the positional filter, --for…
ryanduguid pushed a commit to yungSNAT/PascalsLetter that referenced this pull request Nov 2, 2026
…ext resync starts there (#34)

* chore: contributor tooling

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* chore: drop BREAKING_CHANGES.md and the release simulation script

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(release): one release and one v-tag for every crate

release-please releases the root package only, with tags kept as
v<version>. The workspace members (engine, plugin, text) follow it as the
Rust strategy bumps them; proxy/ is listed as an extra file. A per-crate
setup would have published five releases with component tags, which the
installers, Homebrew and winget cannot resolve.

Also: the secret scanner's sk- label names no vendor, and the editions
page uses the same install script URL as the README.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(pipeline): route before an edit is refused; the hook load rejects tasks named after real commands; a blank message keeps a reason

- `route` at hook.before_tool.edit is E005. The hand-over sent the edit to a
  `generate` task, which rewrote the whole target from a spec and the
  reference alone. A route at `any` no longer hands an Edit to `generate`.
- The hook-path load applies the same reserved-command check as `validate`
  and `pipeline add`, so a `[task.make]` file is dropped there too instead
  of routing a command to `rtk make`, which runs make.
- An empty or blank `message` on a deny or route falls back to the default
  text, so no host ever receives an empty block reason.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(hooks): pipeline hook rules act on Claude Code only; the shared hook core is develop's again

decision.rs, rewrite_cmd.rs, hook_check.rs and the OpenCode, Pi, Hermes and
OpenClaw plugins are back to develop. Claude Code's hook consults the
pipeline from its own module (hook_cmd/claude_pipeline.rs, engine only)
before the 0.x flow: deny, route and answer block with their message, a
swap is only offered where develop would rewrite and always asks, and
with no pipeline file the hook is unchanged. `rtk rewrite` no longer
prints anything at exit 2.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* chore(deps): bump encoding_rs from 0.8.35 to 0.8.42

Bumps [encoding_rs](https://github.com/hsivonen/encoding_rs) from 0.8.35 to 0.8.42.
- [Commits](https://github.com/hsivonen/encoding_rs/compare/v0.8.35...v0.8.42)

---
updated-dependencies:
- dependency-name: encoding_rs
  dependency-version: 0.8.42
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>

* chore(deps): bump ignore from 0.4.25 to 0.4.32

Bumps [ignore](https://github.com/BurntSushi/ripgrep) from 0.4.25 to 0.4.32.
- [Release notes](https://github.com/BurntSushi/ripgrep/releases)
- [Changelog](https://github.com/BurntSushi/ripgrep/blob/master/CHANGELOG.md)
- [Commits](https://github.com/BurntSushi/ripgrep/compare/ignore-0.4.25...ignore-0.4.32)

---
updated-dependencies:
- dependency-name: ignore
  dependency-version: 0.4.33
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>

* chore(deps): bump serde from 1.0.228 to 1.0.229

Bumps [serde](https://github.com/serde-rs/serde) from 1.0.228 to 1.0.229.
- [Release notes](https://github.com/serde-rs/serde/releases)
- [Commits](https://github.com/serde-rs/serde/compare/v1.0.228...v1.0.229)

---
updated-dependencies:
- dependency-name: serde
  dependency-version: 1.0.229
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>

* ci(release): fix the rtk-proxy build on x86_64-unknown-linux-musl

sqlite-vec 0.1.9, linked through icm-store, declares uint8_t, uint16_t and
uint64_t from the BSD u_int*_t names, which musl does not provide. Define
them to the C99 types for that target so the daemon compiles there like
on every other target.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* fix(ng): preserve argument boundaries in tracking labels

* fix(task): resolve command and platform shell in task execution

Use resolved_command and child_args in run_for_output to support PATHEXT
wrappers and child argument escaping across platforms. Route
api_key_command through shell_command to use the platform default shell
instead of hardcoding sh, and add tests for credential command execution.

Signed-off-by: Vinícius DS Andrade <[email protected]>

* fix(task): honor custom claude config directory in transcript resolution

Use resolve_claude_dir instead of hardcoding the user home directory
so that custom CLAUDE_CONFIG_DIR configurations are respected when
locating project transcripts for displaced model tracking.

Signed-off-by: Vinícius DS Andrade <[email protected]>

* fix(gain): only suggest `rtk pipeline share` where the command exists

The Apache-2.0 edition is built without the pipeline engine, so it has no
`rtk pipeline` command, yet `rtk gain` and `rtk gain --watch` still told
its users to run `rtk pipeline share`. The hint now shows only in builds
that have the command, and the dashboard drops the empty panel.

Fixes #4557

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(proxy): honour RTK_PROXY_BIND in `llm-proxy start` and the agent launchers

The daemon and `rtk llm-proxy status` read RTK_PROXY_BIND, but
`rtk llm-proxy start`, `rtk claude` (and the other launchers) and the
`rtk gain` hint still used 127.0.0.1:8080. With another service on 8080,
`start` failed and `rtk claude` waited 15 s, then ran the agent unproxied.
All three now use the configured address; an explicit `--bind` still wins.

The SessionStart hook that `rtk init -g` registers now restarts the
daemon on the port the session's ANTHROPIC_BASE_URL points at, so it
cannot start it on a different port than Claude Code is calling.

After `rtk llm-proxy start --bind <addr>`, the instructions now include
`export RTK_PROXY_BIND=<addr>`, without which `status` and `rtk claude`
look for the daemon on the default port.

Fixes #4556

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* chore(deps): bump actions/upload-artifact from 4 to 7

Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 7.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v7)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <[email protected]>

* chore(deps): bump actions/checkout from 4 to 7

Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <[email protected]>

* chore(deps): bump googleapis/release-please-action from 4.4.1 to 5.0.0

Bumps [googleapis/release-please-action](https://github.com/googleapis/release-please-action) from 4.4.1 to 5.0.0.
- [Release notes](https://github.com/googleapis/release-please-action/releases)
- [Changelog](https://github.com/googleapis/release-please-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/googleapis/release-please-action/compare/5c625bfb5d1ff62eadeeb3772007f7f66fdcf071...45996ed1f6d02564a971a2fa1b5860e934307cf7)

---
updated-dependencies:
- dependency-name: googleapis/release-please-action
  dependency-version: 5.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <[email protected]>

* fix(permissions): match deny and ask rules past assignments and wrappers (#4281)

Claude Code matches a deny or ask rule past any leading assignment and past a
fixed set of wrappers — `timeout`, `time`, `nice`, `nohup`, `stdbuf`,
`command`, `builtin`, `noglob` and a bare `xargs` — so `Bash(git push *)` stops
`HUSKY=0 git push` and `timeout 30 git push` as well as `git push`. RTK's gate
matched the segment as written and nothing else, so every one of those forms
fell through to Default; under a host that treats Default as its own decision,
as #3909's OpenClaw does, they auto-apply.

The rewrite already looked through the same prefixes: `timeout 30 git push`
became `timeout 30 rtk git push` while the gate did not recognise `git push`
inside it. That was two readings of which command a line runs, and the gap
between them is this bug. The deny and ask loops now also try every reading
`matching_readings` gives: the rewrite's own peels — its env peel, its keyword
prefixes, its wrapper table — applied one prefix at a time, each assignment and
each `env` word a prefix of its own, each intermediate command kept. Where both
look through a prefix, the gate and the rewrite now read the same command
behind it, and a rule may name any layer: `HUSKY=0 git push *` stops
`env HUSKY=0 git push`, and `nohup HUSKY=0 git push` too, where denying is
stricter than the shell, since nohup looks for a program named `HUSKY=0`. The
readings start after the split's leftover grammar is gone, since
`{ HUSKY=0 git push; }` reaches the gate as `{ HUSKY=0 git push`. They are
computed once per segment, not once per rule, and stop after 32 prefixes; past
that the command behind them is unread, so where a deny or ask rule exists the
verdict is at least Ask and an allow rule on the outer wrapper cannot answer for
it. With no deny or ask rule, none of this is read.

Two wrappers the host strips were not in the rewrite's table, for a reason that
still holds: `stdbuf` exists to unbuffer the wrapped command's output, which
routing through rtk would buffer, and `xargs` runs whatever it is handed.
Recognising a command is not the same decision as rewriting it, so they sit in
a match-only table the rewrite never consults. `xargs` is peeled only in its
bare form, as the host does; any option makes the wrapper grammar give up.

The line is drawn at the shell. `env`, `exec` and `nocorrect` are peeled too,
though the host's list stops short of them; each runs the next word as the
command. A program's own arguments are not: `git -C . push` stays `git`, as the
host documents, since reading past it would mean knowing every program's option
grammar.

Allow is deliberately left as it was. The host matches allow past known-safe
variables only, and peeling a wrapper for it would let `timeout 30 <cmd>`
inherit `<cmd>`'s allow rule — so the peel can only make a verdict stricter.

The gate reads prefixes with the rewrite's own peels, so it stops where they
stop; there it reads the command as before, so no verdict is looser than it
was. A line setting more than 32 variables before its command now prompts
where a deny or ask rule exists.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* chore(deps): bump windows-sys from 0.59.0 to 0.61.2

Bumps [windows-sys](https://github.com/microsoft/windows-rs) from 0.59.0 to 0.61.2.
- [Release notes](https://github.com/microsoft/windows-rs/releases)
- [Commits](https://github.com/microsoft/windows-rs/commits)

---
updated-dependencies:
- dependency-name: windows-sys
  dependency-version: 0.61.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <[email protected]>

* chore(deps): bump which from 8.0.1 to 8.0.6

Bumps [which](https://github.com/harryfei/which-rs) from 8.0.1 to 8.0.6.
- [Release notes](https://github.com/harryfei/which-rs/releases)
- [Changelog](https://github.com/harryfei/which-rs/blob/master/CHANGELOG.md)
- [Commits](https://github.com/harryfei/which-rs/compare/8.0.1...8.0.6)

---
updated-dependencies:
- dependency-name: which
  dependency-version: 8.0.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>

* test(hook): probe the ls exit code, cover a clean git diff, isolate the python probe

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(git): no log cap notice under git's usage on git 2.56

git 2.56 answers `-h` with its usage and exit 0, where every earlier
release exited 129. `rtk git log` read "git refused the walk" from an
exit code of 128 or more, so on 2.56 `rtk git log -hp` ran the cap
probe after the usage text, the probe got the usage again, read it as
commits left over, and appended "[rtk] capped at 10 commits" under it.
`a_clustered_h_is_answered_by_git_once` fails this way on the macOS and
Windows runners that moved to git 2.56 (2.55 still passes).

A `-h` flag, alone or in a cluster, now counts as a walk that never ran.
Only a flag token does: `--grep -h` and `-- -h` still run the walk and
still get the notice.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* docs: drop hard-coded agent count in hooks/README.md

* docs: drop hard-coded command count in openclaw/README.md

* docs: drop hard-coded agent and mode counts in src/hooks/README.md

* docs: document the rtk bypass escape hatch in the README

* test(js): pin which coverage rows the cap keeps and what the hint stores

The cap had one capped fixture, and its single row below 100% never made
the structure compete for the budget. Four mutations passed on it:
skipping the structure pass, running the gap pass first, not counting the
rules as structure, and emitting the table reversed. A 30-file run with
every file below 100% is a 34-line table, and asserting it comes out as
bun's first 19 lines plus its closing rule turns all four red.

The hint was checked for presence only. A test build runs in recall
mode, so the branch reading the tee path never ran, and storing
hidden[1..] or offset 2 both passed while losing the first dropped row.
The test now follows the hint the way a caller does, through the entry
or the file, and compares with the rows the cap actually dropped.
Reading an entry back applies shown_upto, which is where recall starts,
so the offset is pinned too.

The at-cap test used a hand-trimmed table that bun would not print; it
is a real 16-file capture now, which bun prints as exactly 20 lines.

* fix(hermes): preserve legacy rewrites and other plugin directives

* test(cli): pin which invocations run the telemetry ping

maybe_ping is inert in any build without RTK_TELEMETRY_URL, test builds
included, so no test could tell whether a hook command pings. The
decision now lives in pings_telemetry, taken on the parse result, with
one call site; the tests cover parsed hook commands and the invocations
clap rejects (help, version, passthrough, a hook with a bad flag).

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(init): only offer the proxy when rtk-proxy can run, and remove its base URL on uninstall

* fix(json): print --keys-only schemas compactly

An indented schema can cost more than the minified input it replaces
(leak.json 31 -> 36 B, a 4-level chain 30 -> 79 B), which undercuts the
point of --keys-only now that it always prints the schema. Render it as
`{a:int,b:string,c:int}` with no indentation or newlines; the same
inputs are then at or under raw (31 -> 23 B, 30 -> 23 B), as are the two
real captures in tests/fixtures.

Only emit the key cap when keys are actually omitted: the old check ran
after pushing index 15, so a 16-key object printed "... +0 more keys".
It now shows 16 keys and appends "...+N" only when more remain.

The guard.rs paragraph drops the size argument for the same reason: the
schema is printed because it is the answer, and in compact form it is
also the smaller output.

* fix(hook): verify integrity for rewritten RTK commands

Port the seven missing operational-command entries and regression coverage
to current develop without importing unrelated commit history.

* fix(discover): route only dotnet build and restore to rtk dotnet

`rtk dotnet test` and `rtk dotnet format` change what the native command
does: format turns into a --verify-no-changes check, and test injects TRX
reporting into a temp results directory it deletes afterwards (breaking
MTP runs, --list-tests, and coverage/trx artifacts). Narrow the rule to
`build|restore` until #4602 and #4603 land, and assert that `dotnet test`
and `dotnet format` are not rewritten.

Also list `dotnet restore` in the .NET table of what-rtk-covers.md.

* fix(hooks): leave git unrewritten in Claude Code managed worktrees

Claude Code's worktree-isolation guard evaluates the command after the
PreToolUse hook has rewritten it, and it can only verify git when it is
spelled plainly. Every `rtk git …` the hook emitted was therefore refused
as "cannot be shown not to be git", so no git command could run from a
worktree-isolated session.

The payload carries no isolation flag, but managed worktrees always live
at `<repo>/.claude/worktrees/<name>`. When the payload cwd has that shape,
the Claude hook adds `git` to the exclude list so it passes through
untouched while the rest of the chain is still rewritten. Other hosts
pass no cwd and are unchanged.

Fixes #3864

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* test(hooks): pin the Claude Code worktree git exclusion

Pin the worktree exclusion: another git verb, a non-worktree .claude
directory, and the user's own exclude_commands kept alongside it. The
doc comment now says a worktree placed outside .claude/worktrees is not
detected, and the path check uses CLAUDE_DIR.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(dotnet): preserve test discovery output and requested reports

* fix(init): freeze legacy Antigravity texts and make migration best-effort

Addresses Round 1 review on #4335:
- Freeze the three historical release outputs (v0.36-v0.43, v0.44-v0.48, v0.49-v0.50) as literal constants and drop RTK_AWARENESS_HIGH, RTK_AWARENESS_DEFAULT, and live RTK_AWARENESS_FULL from the legacy matcher and test fixtures.
- Make legacy rules migration best-effort (warn on stderr and continue on I/O errors) and run it after writing plugin files on install.
- Pin CRLF exact match, CRLF suffix preservation, -g --uninstall workspace cleanup, exact vs suffix summary lines, and single-newline separator rejection with tests.

* fix(gain): exclude zero-input rows from exec time totals

`rtk gain` summed `exec_time_ms` over every tracked row. Rows recorded
through the passthrough/fallback path carry no input (`input_tokens = 0`)
and their duration is the wrapped process's whole lifetime, so a single
interactive TUI launched as `rtk claude` adds hours of wall-clock time.
On the reporter's install 131 such rows were 99.7% of `Total exec time`
and inflated the average 865x (9m5s reported vs 1.15s of real filtering).

Only rows with captured input now count toward the exec-time total and
its average, in the summary and in the daily/weekly/monthly breakdowns
(text, JSON and CSV), including the TOTAL row of the period tables.
Command counts, token totals and savings percentages are unchanged.

Closes #4003

Signed-off-by: Raymond Riter <[email protected]>
Co-Authored-By: Claude Opus 5 <[email protected]>

* chore(pytest): drop the two new comments

Signed-off-by: jaideeppyne <[email protected]>

* perf(python): compact repeated lint descriptions

* ci: increase test worker stack for debug parser

Give cargo test workers an 8 MiB stack so the large debug Clap parser builders can complete on Windows. The Windows test job aborted in Bun parser tests with STATUS_STACK_OVERFLOW.

* feat(git): fold `rtk git ls-tree --name-only` path lists by directory

`--name-only` and `--name-status` listings print one line per run of
paths sharing a directory (`src/cmds/git/ diff_cmd.rs gh_cmd.rs`), in
git's order, so every path rebuilds line for line. Paths with a space
or git's C-quoting stand alone in quotes. Every other form, a one-path
lookup, and a failed run pass git's bytes through unchanged.

The hook leaves a folded form unrewritten in front of `head`, `tail`
and `cat`, so those see git's lines. Measured 37.4% on rtk's own tree
(798 paths, 26,211 B to 16,398 B).

* chore(task): apply rustfmt to the api_key_command error

cargo fmt --check failed on the bail! added for an unrunnable
api_key_command, which is a gate the project requires before merge.
Formatting only, no behavior change.

* test(init): pin that remove_rtk_block stops at the first closing marker

A closing marker the user quotes after the RTK block must not extend the
removal over their prose.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* test(permissions): pin the load-failure warning and deny rules past a parse failure

The read-failure warning and the delegate exit code are checked end to end
through `rtk rewrite` with `RTK_REWRITE_HOST=openclaw`, for a settings file
that cannot be read and one that cannot be parsed. A unit test checks that
deny rules from the other settings files still apply after an unparseable
one, as they already did after an unreadable one. The new test helpers say
why they would panic.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* test(ruff): pin the analyze graph exemption on captured output

Replay real `ruff analyze graph` and `ruff rule E501` output (ruff 0.16.6)
through `rtk ruff`: the graph comes back whole and valid JSON, other
passthrough subcommands keep the cap. The unit tests now use the same
captured graph instead of a hand-built one, and pin which invocations
the classifier exempts.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(analytics): degrade unsupported weekly retry without losing report sections

* test(ng): pin workspace, table and process capture decisions

* fix(init): handle Windows CRLF suffix and guard against non-regular files

* fix(init): require the rtk-proxy binary for the on-ramp, gate binary_available, name the removed base URL

* fix(hooks): preserve Claude status semantics and refine agent hook probes

- Restore develop's Claude hook status behavior so legacy script settings remain subject to version checks and deleted scripts report as missing.
- Remove Cursor outdated detection and parallel outdated checks to keep warning formatting pure.
- Drop unregistered script fallbacks in Gemini and Cursor to avoid false detections from orphaned files.
- Simplify agent probe definitions and eliminate redundant byte order mark handling across agent configs.
- Add hermetic in-process test isolation suites to verify all agent probes and warning suppression paths across platforms.
- Pin all Claude status states, agent probe branches, and full install and uninstall cycles across gain and daily check entrypoints.

* docs(pipelines): name the shell api_key_command runs under

api_key_command now goes through the platform default shell rather than
a hardcoded sh, which on Windows means cmd. A key command written for
Git Bash silently stops working there unless it is rewritten or wrapped
in an explicit `bash -c`, so the upstream table says which shell runs
it, as asked in the review of #4555.

Signed-off-by: Vinícius DS Andrade <[email protected]>

* test(task): hard-code the transcript slug the test expects

Deriving the expected slug with the same map the code applies proves
only that the two agree, not that either is right. A fixed path with a
`.` and, on Windows, a `:` in it pins what the slug rule does to each
character, and the fixed path has no `to_str().unwrap()` to need.

Signed-off-by: Vinícius DS Andrade <[email protected]>

* test(task): run a .cmd wrapper on PATH through run_for_output

`Command::new("npm")` cannot start the `.cmd` wrapper npm installs as
on Windows, which is the failure `resolved_command` fixes in
run_for_output, and nothing exercised it. The test writes a hello.cmd
into a temporary directory and asserts a bare `hello` runs and its
output is captured.

`which` reads the process's own PATH, and the suite never changes the
process environment under the tests running beside it (set_var is also
unsafe on edition 2024 and the crate denies unsafe_code), so the test
runs the test binary again with the directory in front of the child's
PATH, filtered to its own name with --exact, and checks the re-run
reported the one test so a mismatched name cannot pass vacuously. The
two direct environment reads this takes are registered in
DIRECT_READS, beside the one utils.rs already has for the same PATH.

Signed-off-by: Vinícius DS Andrade <[email protected]>

* test(isolation): keep proto and volta roots for a child's shims

A proto or volta shim on PATH finds its toolchain through PROTO_HOME or
VOLTA_HOME, falling back to a directory under HOME. The pinned scratch home
holds none, so on a machine using either manager every isolated go, node,
npm or pnpm printed the manager's error instead: the go -json help test
failed, and the wrapped-help comparisons passed on that error against
itself. Keep both roots, as RUSTUP_HOME is kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* test(recall): pin the 16-char key and old hints after an upgrade

The collision test now asserts both stored keys, so a key length other
than 16 fails it. A new test stores an output under a 12-char key, as the
previous release did, stores it again under the 16-char key, and checks
that each hint returns its own row instead of an ambiguous-prefix error.

The getting-started guide's example hint uses the 16-char form.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(hooks): preserve host checks when settings cannot be loaded

* fix(lint): route SQLFluff file shortcuts through lint

* fix(json): quote --keys-only schema keys that read as syntax

The compact schema prints keys bare between `,` `:` and braces, so a key
containing one of them changed the structure the schema shows:
{"a":{"b:int,c":1}} and {"a":{"b":1,"c":1}} both printed
{a:{b:int,c:int}}. Such keys, and keys with whitespace, control
characters or a leading `...`, are now printed JSON-quoted.

Adds a test for inputs where never_worse would print the raw JSON:
with the compact schema smaller than every earlier test input, routing
--keys-only back through the guard no longer failed any test. Also
states that short string values can make the schema a few bytes larger
than the input, and replaces two unwrap() calls in tests.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* test(cargo): pin nextest combined annotation after passed

A run with retries prints `2 passed (1 slow, 1 flaky), 1 failed, 4 skipped`.
The annotation group accepts any parenthesised text, but the tests only
exercised `(1 slow)`, so narrowing it to `\(\d+ slow\)` passed the suite
while turning this run green. Pin it with a real cargo-nextest 0.9.143
capture.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* docs(guide): drop rows for commands the hook does not rewrite

The page lists what the hook intercepts. `cargo nextest`, `dotnet test`,
`dotnet format` and `docker compose up` were listed, yet `rtk rewrite`
leaves all four alone. `cargo nextest` and `docker compose up` have no
rule yet (#2046, #1727); `dotnet test` and `dotnet format` stay out of
the `dotnet` rule until their handlers match the native commands
(#4603 and #1289, #4602).

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* test(python): pin tie order and truncation of shared lint descriptions

Ruff now orders tied files and per-file rules by name, and both filters
truncate a long shared description in Top rules to 100 characters.
Neither was covered: a count-only sort or an untruncated row passed the
suite. Add real Ruff 0.16.6 and SQLFluff 4.3.0 captures that reach both.

Drop the README claim that Ruff's recovery log keeps full messages: its
lines are truncated like the detail lines, and recovery depends on the
configured mode.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* feat(hooks): wrap unambiguously-fish scripts as rtk run --shell fish

A host can evaluate the Bash tool's command string with a POSIX/zsh layer
while the user's shell is fish, so a fish script (`if … end`, `; and`, `; or`)
fails to parse before RTK is ever consulted. Nothing downstream can recover
from that: the command is already gone by the time a hook would see the error.

Classify the command instead. A script is *provably* fish when a fish-only
keyword (`end`, `begin`, `switch`, `and`, `or`, `not`) sits at a command
boundary and nothing a POSIX shell needs appears anywhere — `then`, `fi`,
`do`, `done`, `esac`, `elif`, `[[`, a heredoc, or a backtick, none of which
fish has. Such a script is handed back as

    rtk run --shell fish -c '<script>'

one quoted argument every host layer parses as a single command, with the
script byte-identical inside it.

The classifier is this module's own; it does not ask the shared lexer to treat
fish keywords as unattestable, because that lexer reads the host's command
string as bash.

Gates, in order, with the pure ones first so a non-fish command reaching this
path does no I/O: not Windows, not already delegating (`rtk …` or an explicit
`-c` wrapper), classified fish, no `\\` or `\'` — the only two sequences where
fish single-quote semantics diverge from POSIX, so every wrapped script
round-trips byte-identically under sh/bash/zsh *and* fish — then
`hooks.wrap_fish_scripts` (default on) and a resolvable `fish` binary.

The wrap is never auto-allowed: its strongest verdict is `Ask`, because the
script's content is not attested. A deny rule still outranks it.

* docs: document automatic fish script wrapping

README auto-rewrite section, the hooks permission-model README, and the
French FEATURES guide (behaviour, gates, the `wrap_fish_scripts` opt-out and
the config sample).

* fix(hooks): classify only code, keep the rewrite, refuse what the gate refuses

Round-1 review of #4127, three defects, one mechanism each.

**Comment text was read as code.** Neither the classifier nor the shared lexer
models `#`, so a separator inside a trailing comment reset command position and
the next English word was matched against the fish markers:
`echo $((1+2))  # x; and y` is a working bash command that was wrapped and then
failed under fish with nothing run at all. Every shell in play agrees where a
comment starts and ends, so the span is dropped before anything is classified.
A `#` inside quotes is still an argument, and a fish script may carry a comment
of its own.

**The wrap cost the rewrite.** It preceded `rewrite_command`, so a fish chain
whose leading command was rewritable lost the savings `develop` delivers —
`git diff HEAD~3 HEAD; and true` went from 76.9% to 0%. The script's own
commands are rewritten first and the wrap carries the result, honouring
`exclude_commands` and `transparent_prefixes` like any other rewrite.

**It was the only path emitting a command RTK could not attest.** It now
refuses everything the unattestable gate refuses — command and process
substitution, file-target redirects — plus fish's own `(cmd)` substitution,
which the bash-shaped lexer reads as a subshell and lets past. What still
reaches the wrap is a script whose only unattestable property is fish control
flow, which is the case the feature exists for, and `src/hooks/README.md` now
records why that one is exempt, what bounds it, and that Codex renders `Ask` as
a protocol-level `allow`.

Also from the review: `tests/fish_wrap_test.rs` asserts on the fish-absent
branch instead of returning early, so the file is no longer inert on a runner
without `fish`, and `decide`'s gate-order doc lists the wrap.

* fix(hooks): gate on the stripped code, and end a comment at an operator

Round-2 review of #4127.

**A quote in a comment blinded the gates** (new in the previous push). The
classification moved to the comment-stripped text but the three gates still
read the raw script, and an apostrophe there opens the shared lexer's quote
state — everything after it collapses into one argument, so no redirect and no
substitution was ever seen while the classifier saw clean fish:

    test -d src; and git status # don't
    cat secrets.env > /tmp/leak

wrapped, and running the emitted form wrote the file out. The gates now read
the same stripped code the classification does, so the second line is seen and
the script defers, as it does on `develop`.

**A comment glued to an operator was still read as code.** `strip_comments`
started a word only after a space, tab or newline, so `;#`, `|#` and `&#` kept
round-1's defect: `echo ${HOME:-x};# note; and more` runs in bash and dies
under fish with nothing executed. Both shells start a word after an unquoted
operator; so does this now.

**The suite was red on any runner without `fish`.** The no-fish branch of
`rewrite_keeps_the_inner_rewrite_inside_the_wrap` asserted a defer, but the
script's leading command is rewritable on its own, so the answer there is the
ordinary rewrite — asserting that exact string pins the wrap as genuinely off
rather than merely absent. `rewrite_defers_fish_script_with_divergent_backslash`
no longer returns early either.

CI installs `fish` on the Linux and macOS test runners, since without it the
wrap never fires and the feature's only code path is never exercised; Windows
has none to exercise, where the wrap is disabled by design. The fixtures no
longer name `/etc/passwd`, which the repo's own semgrep rule flags.

Docs: the script no longer travels byte-identical — its commands are rewritten
first — and `README.md`, `src/hooks/README.md` and the French FEATURES guide
now say so, along with the substitution and file-redirect refusals that make
`for f in (ls) … end` defer.

* fix(hooks): refuse where the shells disagree about a comment

Round-3 review of #4127.

`&` is the one boundary character bash and fish read differently. Since fish
3.0 `&` is job control only at the end of a job, so a `#` glued to it opens a
comment in bash and continues the word in fish:

    bash -c 'echo A&#y'   ->  A        (comment)
    fish -c 'echo A&#y'   ->  A&#y     (one literal word)

`strip_comments` took bash's reading, which hid everything after the `#` from
the gates while the emitted wrap carried it and fish ran it —
`test -d src; and echo HIT&#y > /tmp/LEAK` wrapped and wrote the file. Taking
fish's reading instead re-admits comment text as code, which is the defect
round 1 fixed. Neither is safe, so the scanner refuses: `strip_comments`
returns `None` when a comment start is glued to a lone unquoted `&`, and the
script keeps the host's own behaviour. `&&#` and `||#` still strip — both
shells agree there — and the run is counted only over syntactic ampersands, so
an escaped `\&` before a lone `&` refuses too, and a run never crosses the
newline that closes a comment.

* fix(hooks): keep the fish wrap off wherever a deny rule could match

Round-4 review of #4127. The round-3 answer peeled `and`/`or`/`not` off the
front of a segment, which was both too wide — it changed how every command is
matched against deny rules, not only fish ones — and too narrow: it only ever
looked at the start of a segment. A denied command in the condition of an `if`
or a `while` went straight through, and the wrap handed it to a fish that runs
it where bash refuses the syntax outright:

    deny: ["Bash(rm:*)"]
    if not rm -rf victim\n  echo x\nend      wrapped, and fish deleted the file
    while rm -rf victim\n  break\nend        the same

Peeling keywords chases one placement at a time, and fish starts commands in
more places than a keyword list holds. So the criterion is no longer "peel the
keyword" but **never wrap a script a deny rule could match anywhere**: every
word is treated as a possible command start and the permission gate's own
`command_matches_pattern` answers for each run of words
(`permissions::deny_matches_any_word_run`). The words come from the same
comment-stripped code the classification and the gates read, dequoted through
the shared `lexer::shell_split`, so an apostrophe in a comment cannot hide
them and `'rm'`, `r'm'` and `"rm"` all read as `rm`.

The rules are the host's own, the ones its verdict was judged against:
`decide` takes them, `decide_hook_action` loads them once per host instead of
twice, and `AgentPath` supplies the set its own `verdict` reads.
`check_command` goes with that — `rewrite_cmd` was its only caller and now
loads Claude's rules explicitly, which is what it documented already.

The keyword peel leaves this PR entirely; with the wrap deciding for itself,
nothing here needs it.

Conservative by construction: a denied program named as a plain argument keeps
the wrap off too. That costs a rewrite, never a guarantee. With no deny rules
nothing changes, and a rule naming something else leaves the wrap alone.
`deny_keeps_the_wrap_off_in_every_placement` pins all twelve spellings through
`rtk rewrite` under a real `settings.json`, asserting *never wrapped* rather
than a particular exit code.

* fix(hooks): make the fish wrap opt-in, and ask for it explicitly

Round-8 review. Claude Code does not take the hook's answer as final for a
rewrite: with `updatedInput` and no `permissionDecision`, it runs its own
permission check on what came back. For a wrap, what it checks is one
`rtk run --shell fish -c '…'` command, with the script's own commands invisible
to every rule it has. Measured against Claude Code 2.1.305: an
`allow: ["Bash(rtk:*)"]` ran the whole script unprompted, and a deny from
`--settings`, from `--disallowedTools`, or from a project the agent had `cd`'d
away from stopped applying.

Two holes, and RTK can close only one of them. The gate reads deny entries, so
an allow rule matching `rtk` and an ask rule naming a script command were never
considered — both are closed by the hook stating its own decision. The second
is the rule sources that never reach a hook at all; nothing here can see them.

So the wrap becomes a thing a user asks for, and says so when it happens:

- `hooks.wrap_fish_scripts` defaults to `false`. Without it the Claude hook
  answers exactly as `develop` does.
- Every wrap the hook emits carries `"permissionDecision": "ask"`. An ordinary
  rewrite is untouched: an allow rule still renders as `allow`.
- The user docs say what turning it on costs — the script reaches the prompt as
  one `rtk run` command, and a rule RTK cannot read becomes that prompt rather
  than a refusal.

The deny-entry and read/parse attestation stays as it was: with the flag on, a
rule RTK *can* see still keeps the wrap off entirely.

Six decisions this branch added still passed the suite when flipped, each
changing real output. `only_the_claude_hook_wraps` now drives Codex, Vibe,
Trae, the VS Code hook and the Copilot CLI through their own payload shapes —
the Codex row sent `"tool_name":"shell"`, which that path ignores, so it passed
whatever Codex decided — and each row carries a control proving the payload
reaches the decision at all. `the_pipeline_swap_gate_never_sees_a_wrap` drives
a real swap rule, with the swap itself as the control.

Rebased onto `develop`, whose loader now fails closed: a settings file that
exists but cannot be read or parsed warns and pushes `ask *`. Both behaviours
are kept in `load_permission_rules_tracking`, with `every_file_read` cleared
beside each push, and `load_permission_rules_from(paths)` kept as the wrapper
`develop`'s tests call. One behaviour moves with it, correctly: an empty
settings file counts as read, because `develop` skips it — it holds no rule.

Also from the review's optional list: the attestation is computed only for a
command that classifies as fish, so an ordinary command no longer walks the
settings twice, and four doc passages that described paths this head cannot
take are corrected.

* fix(hermes): preserve earlier hook command directives

* fix(git): preserve ls-tree pipeline lines with global options

* fix(dotnet): retain solution test failure details

* fix(hook): honour opt-outs inside a quoted script behind uv run

A routable wrapper falls through to its own rule when the inner rewrite
is dropped, so `uv run bash -c 'pytest'` under exclude_commands, or
`uv run bash -c 'RTK_DISABLED=1 git status'`, became
`rtk uv run bash -c '…'` and the opted-out command was filtered anyway.

Refuse the fall-through when the wrapped quoted script holds an excluded
command or an RTK_DISABLED= prefix.

Fixes #4704

Signed-off-by: ShryukGrandhi <[email protected]>

* fix(read): separate line numbers with a tab

`--line-numbers` formatted each prefix as `{:>width$} │ `, and the
box-drawing bar is three bytes in UTF-8. That made `rtk read -n` about
4% larger than `cat -n` on a minified source file — the only negative
case in scripts/benchmark.sh, so the benchmark currently fails against
develop's tree.

Use a tab instead, keeping the width alignment. The output now matches
`cat -n`'s shape and is about 4% smaller than it (50,360 -> 47,947
tokens on src/main.rs), so the benchmark case turns from negative to
good.

Update the two line-number expectations in read_window_bytes_test.

* chore(deps): bump getrandom from 0.4.2 to 0.4.3

Bumps [getrandom](https://github.com/rust-random/getrandom) from 0.4.2 to 0.4.3.
- [Changelog](https://github.com/rust-random/getrandom/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rust-random/getrandom/compare/v0.4.2...v0.4.3)

---
updated-dependencies:
- dependency-name: getrandom
  dependency-version: 0.4.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>

* chore(deps): bump serde_json from 1.0.149 to 1.0.154

Bumps [serde_json](https://github.com/serde-rs/json) from 1.0.149 to 1.0.154.
- [Release notes](https://github.com/serde-rs/json/releases)
- [Commits](https://github.com/serde-rs/json/compare/v1.0.149...v1.0.154)

---
updated-dependencies:
- dependency-name: serde_json
  dependency-version: 1.0.154
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>

* chore(deps): bump actions/create-github-app-token from 3.2.0 to 3.3.0

Bumps [actions/create-github-app-token](https://github.com/actions/create-github-app-token) from 3.2.0 to 3.3.0.
- [Release notes](https://github.com/actions/create-github-app-token/releases)
- [Changelog](https://github.com/actions/create-github-app-token/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/create-github-app-token/compare/bcd2ba49218906704ab6c1aa796996da409d3eb1...64ab729a6294000f5aa4a4bc0b73d1f32bc45510)

---
updated-dependencies:
- dependency-name: actions/create-github-app-token
  dependency-version: 3.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <[email protected]>

* chore(deps): bump crossterm from 0.28.1 to 0.29.0

Bumps [crossterm](https://github.com/crossterm-rs/crossterm) from 0.28.1 to 0.29.0.
- [Release notes](https://github.com/crossterm-rs/crossterm/releases)
- [Changelog](https://github.com/crossterm-rs/crossterm/blob/master/CHANGELOG.md)
- [Commits](https://github.com/crossterm-rs/crossterm/commits/0.29)

---
updated-dependencies:
- dependency-name: crossterm
  dependency-version: 0.29.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <[email protected]>

* fix(benchmark): point read cases at src/lib.rs

* test(read): pin multi-digit line-number alignment

A 12-line input pins both the tab separator and the right-aligned width. Reverting either the tab or the width padding fails the test.

* fix(pytest): keep the E lines of error and failure blocks

* fix(pytest): keep pytest's default report with --tb=no

* fix(cargo): keep nextest timeouts and retried failures

The nextest summary regex had no slot for 'N exec failed' or 'N timed out', so a run whose only failure was a timeout took the green branch, and failure blocks only opened on a bare FAIL line, so TIMEOUT and the 'TRY n FAIL' lines of retried tests lost their details. Fixes #4694.

* fix(pytest): keep the failure location and xfail lines, close blocks at section banners

* fix(pytest): mark the E lines cut from a block

* fix(ruff): restore a leading -- before routing

clap's trailing_var_arg strips a `--` that leads `args`, so `rtk ruff -- check`
reached run() as `["check"]`, was routed as the check subcommand with no path,
and linted the whole working directory. Restore it the way golangci and dotnet
do, so the line reaches ruff as typed.

Fixes #4702

* fix(pytest): end the failure section at the warnings summary

* fix(init): return exit code 2 when codex uninstall guard refuses hook (fixes #4164)

* fix(pytest): keep the test frame, read clustered flags with the tokenizer

* fix(init): install the OpenCode plugin independently of agent setup

`rtk init -g --opencode` ran the plugin install after fallible Claude
writes, so a malformed CLAUDE.md block or a read-only ~/.claude exited 1
with no plugin. Hoist the OpenCode install into run(), attempt both
targets, and report each failure independently; the plugin path is only
reported when one is actually in place.

Fixes #4413

* fix(pytest): read shortened block headers, never take source or locals lines as frames

* fix(pytest): match pytest's block separator exactly, never take locals as frames

* docs: align what-rtk-covers.md with hook rewrites and add drift test (fixes #4695)

* test(pytest): pin that a `=====` diff line stays an E line

`is_error_line` leaves out an `E ` line whose text, after its leading
spaces, starts with `= `, so that a local named `E` under `-l` is not
read as an error line. An assertion diff of a reStructuredText heading
produces `E       =====`, which must stay an E line: shown in the window
and counted toward the elision marker. Real pytest 9.1.1 capture.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* test(init): pin the Antigravity legacy-path guard and rtk-prefix check

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* test(hooks): pin foreign hooks and the leftover Claude script

A non-rtk BeforeTool hook in ~/.gemini/settings.json or a non-rtk entry
in ~/.vibe/hooks.toml must not count as an rtk install. A registered
`rtk hook claude` next to a leftover rtk-rewrite.sh reports Outdated.

Also bring the warn_hook_issues doc, the hooks README note on
maybe_warn() and an OpenCode test comment in line with the code.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* test(hooks): run the wrapped-script case, and isolate both fish tests

`wrapped_multiline_fish_script_executes_and_propagates_exit_code` was
`#[ignore]`d, so the one case that executes a wrapped script through
`rtk run --shell fish` never ran: `cargo test --all` skips ignored tests, and
CI now installs `fish` on Linux and macOS. Enable it, and assert on both
branches the way the tests above it do: without a resolvable `fish` the
command must report exit 127 rather than appear to have run.

The direct `sh`/`zsh`/`fish -c` calls in both tests now go through
`common::native_command`, so they get the same isolated home as the rtk child:
`fish -c` sources the user's `config.fish`, and anything it prints landed on
the stdout `wrapped_quoting_round_trips_under_posix_and_fish_hosts` compares
byte for byte, failing the test for any fish user whose config greets them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(hooks): ask for a wrap only when RTK built it, and attest only where one can be

The Claude hook decided a wrap's explicit `ask` by the shape of the rewritten
string alone. So an `rtk run --shell fish -c '…' && git status` the agent typed
itself, which the rules only rewrote around, went from `allow` to `ask`. That
happened even with `hooks.wrap_fish_scripts` off. The explicit ask now needs the
flag on, and a command that did not already start in the wrap form. `try_wrap`
never wraps a command whose first word is `rtk`, so such a command is one the
agent typed: it keeps its ordinary decision. With the flag off, no wrap exists,
and the hook answers exactly as it does without the feature.

The deny-rule attestation walks the settings a second time. It now runs only
where a wrap is possible: the flag is on and the command is provably fish. One
gate, `claude_hook_deny_rules_for`, serves both the hook and
`rtk hook check --agent claude`, so the two read the settings alike. Before,
with the flag off, both walked the settings twice for a fish command and
repeated every loader warning. With the flag on, `rtk hook check` did the same
for every command.

Several comments still said Claude Code takes RTK's decision as final, or that
the files RTK reads are all of its rules. They now say what holds: RTK reads
those files, and the hook asks explicitly for every wrap it builds.
`hooks.wrap_fish_scripts` turns the wrap on, not off.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* refactor(analytics): let the ccusage row aggregation name its granularity

`aggregate_weekly_periods` sums the rows legacy ccusage emits per project
for one week. Monthly reports need the same sum (#4761), so rename it to
`aggregate_periods` and pass the granularity its overflow errors name.
Weekly errors read as before, and weekly behavior does not change.

* fix(analytics): sum per-project ccusage rows in monthly economics

With per-project grouping on (`"groupByProject": true` in its config),
ccusage 18 emits one monthly row per project, and no row carries the
project. `merge_monthly` calls `set_ccusage` once per row on the same
map entry, so each row overwrote the one before it:
`rtk cc-economics --monthly`, the default summary and the JSON `totals`
kept only the last project's spending, and the cost per token derived
from it.

Sum the monthly rows by month with `aggregate_periods`, as the weekly
report has done since #4409. Daily rows are left as they are: ccusage 18
emits one row per day there.

* test(analytics): pin summed per-project rows in monthly economics

Run `rtk cc-economics --monthly --format json` against a ccusage stub
that repeats one month with the two project rows of the weekly
duplicate test. The monthly row and `totals`, which the default summary
prints, must carry the sum of both rows joined with the tracker's
savings. Before the fix they carried the second row only.

* fix(hook): inspect nested quoted shells for opt-outs behind uv run

Signed-off-by: ShryukGrandhi <[email protected]>

* fix(permissions): ignore NotADirectory and strip BOM before empty check

Signed-off-by: Samran Asif <[email protected]>

* fix(hook): preserve fish-wrap decisions and reuse permission scans

* fix(ruff): route flag-first implicit check invocations to check

* fix(ruff): tokenize global options before routing subcommands

Finding the first token without a `-` misrouted invocations that put a
global option before the subcommand: `--config ruff.toml` fed its value
to the routing decision, so `--config ruff.toml format --check` was
treated as an implicit check and rebuilt as one, and an explicit
`check` after `--isolated` was forwarded as a path.

Route on a real pre-subcommand scan instead. Only `--config` and
`--color` consume a value (ruff 0.17 `--help`); every other global
token is a flag, and the first bare token decides the route — a known
subcommand routes, anything else is a path and the run is an implicit
check. An explicit `check` is consumed at its actual position, and
flag-only runs keep the top-level requests (`--version`, `--help`)
while others stay implicit checks. `is_format` now keys on the
effective subcommand, so `help format` no longer reaches the format
filter.

The boolean-predicate tests are replaced by complete CLI cases that
pin the full forwarded argument vector, which is what let the
forwarding regressions slip through before.

* fix(ruff): tokenize with arg_tokenizer and keep help output complete

Route through the shared `arg_tokenizer` (rust-patterns rule 6) instead
of the hand-rolled scan: `tokenize_grammar` with a `Dialect::Posix`
grammar and a `ruff_takes_value` predicate for the two value-taking
global options, so an option value is consumed rather than read as the
subcommand or a path. The first free positional before the `--`
boundary decides the route, and a path after the boundary keeps the
run an implicit check.

Help and version output regressed to the bounded passthrough once the
format filter keyed on the effective subcommand. Extract the output
choice into `filter_ruff_output` and keep `help`/`version` requests and
top-level flags complete — raw Ruff returns every byte, and so does
rtk — while other subcommands keep the bounded passthrough. Pin the
stdout bytes for the complete routes instead of only the forwarded
argument vector.

* fix(ruff): restore the UTF-8 ellipsis in the cap notice

A Windows-1252 round-trip mangled the ellipsis in the '+N more' cap
line and in its test expectation to the mojibake byte sequence.
Restore the proper U+2026 in both.

* fix(ruff): treat check options before positionals as implicit checks

A check-only option's value looked like a free positional to the router,
so `--output-file check keep.py` removed that value as the explicit
subcommand and `--output-file` consumed `keep.py`.

Route the whole invocation as an implicit check whenever an option
outside ruff's global grammar appears before the first free positional:
nothing is removed and no later word is treated as the subcommand. Read
help and version from tokenized flags so a cluster such as `-qh` stays
top-level, and drop the redundant post-loop `--` fallback.

Add plan pins for every router decision named in the review.

* fix(read): book the span that was shown, and test the booked row

With --max-lines the tracking baseline was smart_truncate(raw, N) while
the printed output was never_worse(raw, smart_truncate(filtered, N)): the
two sides truncated different text. A small file printed as cat would
booked a saving, a filter whose result fit the window lost its saving,
and a row could go negative.

The baseline now follows what was printed: when never_worse fell back to
the raw content, or when the window removed nothing from the filtered
content, the baseline is the whole raw content; otherwise it is the raw
content cut to the same window, never smaller than what was shown.

tests/read_tracking_baseline_test.rs runs the binary with an isolated
RTK_DB_PATH and reads the row it booked, for the file and stdin paths,
--head-lines, --tail-lines, --max-lines, -n and a filtered window. Three
of its cases fail on the previous code, one of them with a negative row.
The ignored unit test that spawned the binary moves there with a
positive assertion on the printed code line.

* fix(ls): hold a bare listing to what ls prints, not to the long listing rtk asks for

rtk ls asks its child for a long listing to learn each entry's type and size,
and printed that size after every file even when the user never passed -l.
Without -l, ls prints bare names, so the size column was pure addition: in
rtk's own repository root `ls` prints 341 bytes and `rtk ls` printed 531. The
never-worse guard could not catch it. The runner compares the filtered output
with the child's stdout, and that stdout is the long listing rtk asked for
(2067 bytes), not the listing the user did.

A listing without -l now prints names only, and is held to the output of `ls`
run with the user's own arguments: the compact form is shown only when the
shared never_worse finds it cheaper than that output. The same baseline
governs the two places the filter used to print the long listing verbatim: a
listing it cannot parse (-T and -D dates, the attribute lines of -@ and -e),
and a failed run, where `ls src missing` printed 742 bytes of long listing for
the 108 bytes ls printed. A long listing keeps its existing filter and baseline.

The baseline costs one more `ls` process for a listing without -l.

* chore(deps): bump ignore from 0.4.32 to 0.4.33

Bumps [ignore](https://github.com/BurntSushi/ripgrep) from 0.4.32 to 0.4.33.
- [Release notes](https://github.com/BurntSushi/ripgrep/releases)
- [Changelog](https://github.com/BurntSushi/ripgrep/blob/master/CHANGELOG.md)
- [Commits](https://github.com/BurntSushi/ripgrep/compare/ignore-0.4.32...ignore-0.4.33)

---
updated-dependencies:
- dependency-name: ignore
  dependency-version: 0.4.33
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>

* chore(deps): bump clap from 4.5.60 to 4.6.7

Bumps [clap](https://github.com/clap-rs/clap) from 4.5.60 to 4.6.7.
- [Release notes](https://github.com/clap-rs/clap/releases)
- [Changelog](https://github.com/clap-rs/clap/blob/main/CHANGELOG.md)
- [Commits](https://github.com/clap-rs/clap/compare/clap_complete-v4.5.60...clap_complete-v4.6.7)

---
updated-dependencies:
- dependency-name: clap
  dependency-version: 4.6.7
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <[email protected]>

* test(read): pin the stdin baseline when the window cuts nothing

run_stdin computes window_cut on its own; only the file path had a test
for a filtered window that removed nothing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* refactor(discover): one segmenter behind the permission and classify walkers

`split_for_permissions` and `split_on_operators` each walked the token stream
themselves. The differences between them were real and deliberate — the gate
must break on everything a command could hide behind, classification must not
pretend to — but they were emergent, spelled out only in a comparison table
in a doc comment and re-derived in two places.

Both now call one `segment(cmd, Policy)`. The differences become a `Policy`:
which newline mode, whether `&`/`(`/`)` end a segment, whether redirects are
kept or excised, whether to stop at the first pipe. What was a table of
accidents is now a pair of constants.

`Segment` carries the byte range each command occupies as well as its text,
which the rewrite needs to splice around a command rather than rebuild the
line from tokens.

Behaviour is unchanged, and that is the point, so it is asserted rather than
claimed: both walkers are kept under `#[cfg(test)]` and a generated corpus of
over five thousand compound commands — every command shape crossed with every
separator, wrapper and redirect form — must segment identically through the
old walker and the new policy. Mutating the new segmenter (dropping `)` as a
boundary, ignoring `stop_at_pipe`) fails that test on a named input.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

* fix(hook): rewrite commands without rewriting the text between them

`rewrite_compound` split a compound command on each operator, rewrote the
segments, and then rebuilt the line by emitting `" {op} "` around every
operator it had split on. The operators came back, but the spacing was the
emitter's rather than the author's, and anything it did not model was
invented: `;;` came back as `; ;` and `;&` as `; &`, which is how a `case`
statement became a syntax error and a fall-through became a background job.
Those two had to be fixed in the lexer because the emitter could not tell
them apart from two separate operators.

The emitter now copies the gaps instead of rebuilding them. A segment is
emitted as its own leading whitespace, its rewrite, and its own trailing
whitespace; the operator between two segments is copied byte for byte from
the source. The multi-line path keeps a line's trailing whitespace as well as
its indent.

Wrapper prefixes had it too: `strip_word_prefix` trims the remainder so the
inner command can be matched, and the three sites that rejoined it used a
single space, so `uv run  ls` and `timeout  5  cargo build` came back
respaced. `prefix_gap` hands the original back instead.

The pipeline rewriters had the same habit one level down, joining producer,
`|` and consumer with `format!("{} {}", ..)` over trimmed text, so
`ls -la  |  head -3` came back single-spaced around the pipe. They now carry
the gaps either side of the `|` through as well. That was not a regression —
it predates this commit — but it is the same defect, and leaving it would
have made this commit's claim false for every pipeline.

What changes, all of it the author's formatting coming back intact:

    ls -la  &&  ls     ->  rtk ls -la  &&  rtk ls      (was ` && `)
    ls -la  |  head -3 ->  rtk ls -la  |  head -3       (was ` | `)
    uv run  ls -la     ->  uv run  rtk ls -la           (was `uv run `)
    ls ; ls            ->  rtk ls ; rtk ls             (was `; `)
    ls;ls              ->  rtk ls;rtk ls               (was `; `)
    ls &&\nls          ->  rtk ls &&\nrtk ls           (was ` && `)
    case … 1;;esac     ->  … 1;;esac                   (was `;; `)

Five tests pinned the old normalisation and now pin the input's own spacing.
Each rewritten form was checked with `bash -n`.

The property is what makes this checkable rather than claimed: for a
generated corpus, deleting every inserted `rtk ` from the output must give
back the input. It fails on a named input if the emitter is put back the way
it was. It also caught a case no example covered — a line's trailing
whitespace was dropped before a newline — which is fixed here rather than
excused in the assertion. Its one exemption is the whole-command `trim()`
that happens before the emitter is reached.

Its first version asserted nothing about pipelines: none of the commands it
generated is a safe pipe consumer, so every pipe case returned `None`. The
corpus now crosses real producers and consumers with irregular spacing around
the `|`, which is what surfaced the pipeline rewriters above.

Separators are safe to reproduce verbatim: `;`, `&` and `|` each end a word
on their own, so a rewrite ending in a digit cannot fuse with them. A
redirect can — `--head-lines 1` beside `>out` reads as an fd-1 redirect — but
that is settled before the emitter by `join_redirect_suffix`, and both cases
already had tests.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

* fix(ls): hold long listings to what ls prints too, and never turn a rejected command into an empty success

A listing with -l is now held, like a bare one, to the output of ls run
with the user's own arguments. When ls rejects the command line outright
(non-zero exit, nothing on stdout) while rtk's listing succeeds, there is
no listing to hold to, so rtk lists as before instead of printing nothing
with exit 0.

* test(ls): keep bare -h on a listing rtk compacts, and check the passthrough on its own

a_tools_own_dash_h_is_still_filtered runs `ls -h` again, on a directory of
60 files where the compact form wins, so it still proves rtk filters a
listing that carries -h. The two-file directory, where rtk now prints
exactly what `ls -h` prints, gets its own test.

* test(init): pin the uninstall exit codes outside the refused-hook cases

An uninstall that fails for another reason exits 1, and every other
agent's completed uninstall exits 0. The codex test now also checks
that AGENTS.md is cleaned when the hook is refused.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* test(dotnet): pin reported TRX aggregation and make its solution test deterministic

The solution-root test wrote its reports within one clock tick of the run
start, so the since filter could drop them; it now waits first, asserts the
summed counts and gives the fallback report a failure that would show if it
won. Zero-test reports still count as a project, and the user-directory case
without a logger is kept beside the one with it. Results File paths are
printed unquoted, so the quote trim goes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* test(discover): pin the ls-tree pipeline guard's whitespace and subcommand checks

A double space after `git` still reaches the git rule with `-C`/`-c`, and
other git subcommands taking `--name-only` keep their pipeline rewrite.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* docs(root): add backlink to pro

* test(init): pin the OpenCode gate and the both-failure report

The OpenCode install now runs before the agent setup and reports each
failure. Two decisions it adds had no test: that no plugin is written
without --opencode, and that a double failure reports both causes. Each
test fails on a mutation of its decision. The double-failure test skips
where the filesystem ignores directory modes (root).

Co-Authored-By: Claude Haiku 5.5 <[email protected]>

* test(sqlfluff): pin a long option named like a subcommand

A long option whose name matches a SQLFluff subcommand (--format) must not
be read as that subcommand. Without the positio…
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

rtk task: api_key_command needs sh, digest -- <cmd> skips the resolver, transcript lookup ignores CLAUDE_CONFIG_DIR

2 participants