Skip to content

Release v0.9.1 - #276

Merged
slowdini merged 30 commits into
mainfrom
dev
Aug 16, 2026
Merged

Release v0.9.1#276
slowdini merged 30 commits into
mainfrom
dev

Conversation

@slowdini

@slowdini slowdini commented Aug 16, 2026

Copy link
Copy Markdown
Owner

Release notes

Highlights

  • Windows is a working platform. Prior releases shipped a Windows binary that
    could not spawn a shell for harness lint --probe or multi-turn dispatch, wrote
    malformed paths into every generated artifact, and refused legitimate writes
    through the sandbox guard. This release fixes the platform end to end — path
    rendering, shell discovery, command quoting, long paths, working-directory
    spelling, and the jq pipelines the shipped recipes depend on — and the test
    suite now runs on windows-latest in CI so it stays fixed.

  • Generated artifacts are a wire format, spelled the same way everywhere.
    dispatch.json, conditions.json, judge-tasks.json, dispatch-manifest.md,
    RUNBOOK.md, the guard's deny reasons, and the shadow report now render every
    path with forward slashes regardless of which host produced them. On Windows
    these fields previously mixed separators within a single value
    (/work/cond\run.json). Linux and macOS output is byte-identical to 0.9.0.

  • The POSIX shell and jq requirement is stated instead of assumed. run
    preflights for both and warns up front, rather than handing you a correctly
    prepared workspace full of commands your host cannot execute. The requirement
    names jq alongside the shell because the parallel-dispatch and judge recipes
    are jq pipelines, and Git for Windows bundles sh, xargs, tr, and wc
    but not jq.

Behavior changes to know about

  • Platform support is now explicit. Linux and macOS are supported. Windows
    through Git Bash works but is deprecated. Preparing a workspace on Windows and
    dispatching it from WSL is unsupported and fails quietly: recipes carry the
    absolute paths of the preparing host, and WSL resolves its own filesystem
    namespace where a C:\... path names nothing. Earlier guidance suggested that
    split; it no longer does.
  • EVAL_MAGIC_SH overrides shell discovery. Without it, sh is resolved from
    PATH, then a Git for Windows install, then /bin/sh. bash is never
    accepted, because System32\bash.exe is the WSL launcher and resolves a
    different filesystem namespace.
  • run prints a preflight warning when no POSIX shell or jq is present. It
    warns and continues rather than failing — run never dispatches, so preparing
    on a host that cannot dispatch stays valid.
  • Path fields in generated artifacts change spelling on Windows as described
    above. Anything parsing those fields on a native separator should read forward
    slashes. Unix output is unchanged.
  • Contributors: .gitattributes now checks out every text file as LF, not
    just tests/golden/**. A Windows checkout with the default core.autocrlf=true
    previously produced a CRLF working tree, and since harness descriptors and
    profiles are embedded verbatim in generated artifacts, that silently changed
    program output.

Fixes

  • harness lint --probe and multi-turn dispatch could not start on Windows at all
    /bin/sh was hardcoded in both production paths. They now resolve a real
    shell; the shipped POSIX exec_templates run unmodified.
  • An eval author's quoted command_check silently arrived at the child split at
    its spaces on Windows. Command::arg escapes embedded quotes as \", which
    cmd.exe does not understand; the command line is now handed over verbatim.
  • The write guard denied legitimate writes on Windows. Two independent causes:
    agents spell paths POSIX-style on every host, and std::path::absolute grafted
    the process drive onto them, so /dev/null became C:\dev\null and
    cmd >/dev/null was refused as a file write. Separately, a single directory can
    have several valid Windows spellings (8.3 short name, junction, subst drive),
    and nothing forced the two sides of the guard's comparison onto one — so a write
    relative to a long-form cwd under a short-form root was denied. A genuine escape
    was always still denied; the guard was falsely strict, never weaker.
  • The judge recipe reported 0/N verdicts present and parallel dispatch handed
    every task a corrupted eval_root, dispatch_prompt_path, and outputs_dir on
    Windows. jq's native Windows build opens stdout in text mode, so every \n
    arrived as \r\n and the carriage return rode on the end of each path.
  • A run under a deep workspace could commit a baseline that did not contain the
    skill under test. Staged .claude/skills/<slug>/SKILL.md crosses Windows'
    260-character limit, and the task repository's deliberate config isolation
    discarded any global core.longpaths. The loud form aborted with
    Filename too long; a few characters deeper, git add warned, exited zero, and
    committed silently — so every later diff would have been measured against a
    baseline missing its subject.
  • skipped_prompt_unread stayed 0 on Windows, recording a dispatch that never
    received its instructions as valid data. The check searched serialized JSON for
    a raw path, and serialization escapes every \ separator. It now walks the
    args' string leaves and compares with separators normalized — which also matters
    off Windows, since the dispatch path is wire format while the transcript echoes
    the agent's own spelling.
  • A stray-write report could come back clean for an arm that read the live skill
    source, presenting a contaminated arm as comparable data.
  • The staged-source collision check let a refutation through on exactly the
    collision it exists to block, having found no basename to compare.
  • harness lint --probe reported false failures for {guard_args} in codex's
    parallel and judge command templates. The placeholder had no render-only
    stand-in, though the real dispatch path resolves it fine.

Internal changes

  • CI runs the test suite on ubuntu-latest and windows-latest with
    fail-fast: false; clippy runs on both, since the #[cfg(windows)] arms are
    lint-invisible on Ubuntu.
  • Tests are gated on capabilities rather than on the OS. #[cfg(unix)] hid tests
    from compilation and clippy on the other host and hid the coverage gap with
    them; report_skip prints why a test skipped, and
    EVAL_MAGIC_REQUIRE_POSIX_TOOLS=1 — set on both runners — turns any skip into a
    failure.
  • A hidden __fixture subcommand supplies the predictable child process the suite
    needs, in one invocation that sh -c and cmd /C parse identically, replacing
    two divergent dialects of shell stub.
  • Ten guard tests were passing through a fail-open path while appearing to cover
    the guard: they built marker and manifest JSON with format!, and an
    interpolated Windows path embeds invalid JSON escapes. They now serialize with
    serde_json, as the production installer does.
  • core::fs gains artifact_path and normalize_separators, the rendering and
    comparison halves of the wire-format work.
  • The prompt-read guard moves out of record_runs.rs into
    record_runs/prompt_read.rs, bringing the module back under the size threshold.
  • The long-path tests canonicalize their base before padding: a runner's %TEMP%
    can be an 8.3 short name that git expands before measuring, which moved the
    deepest test across a ceiling and read as a Windows-only product failure.
  • docs/developer_overview.md records the platform support tiers; contributor
    docs updated for the third gated capability.

slowdini and others added 30 commits August 13, 2026 17:08
Merge pull request #242 from slowdini/dev
Two platform assumptions kept `cargo test` from running at all on Windows.

`tests/run/conversation.rs` imported `std::os::unix::fs::PermissionsExt`
unconditionally. Because the file is part of the `run` test target, the
resulting compile error aborted `cargo test` and `cargo clippy
--all-targets` before any test executed. The scripted-turn test that needs
it drives a `#!/bin/sh` stub it has to mark executable, so it and its
imports are now gated `#[cfg(unix)]`; the other two tests in the file run
everywhere.

`.gitattributes` pinned only `tests/golden/**` against EOL translation, so
a Windows checkout with the default `core.autocrlf=true` produced a CRLF
working tree everywhere else. Harness descriptors and profiles are embedded
verbatim in generated artifacts, and `tests/fixtures/**` is compared byte
for byte, so CRLF silently changed program output. Checking every text file
out as LF keeps generated bytes identical across platforms.

Verified on Windows 11 with rustc 1.97.1:
- `cargo build`, `cargo fmt --check`, and `cargo clippy --all-targets --
  -D warnings` all pass; clippy previously could not compile.
- `cargo test --no-fail-fast` reaches 1009 passing, up from 886 with the
  123-test `run` target unable to build.

The remaining failures are path-separator and POSIX-spawning assumptions,
tracked separately.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fix(tests): let the suite build and compare correctly on Windows
`Path::join` plus `Display` emits the host's separator, so on Windows a
POSIX-rooted base yields `/work/cond\run.json` — malformed for every reader
of an artifact that is, by design, a wire format shared across platforms.

`artifact_path` renders a path into that wire format: forward slashes, with
a verbatim `\?\` prefix stripped (verbatim UNC collapsed back to `\server\share`
rather than left as a bare `UNC\` component). The rewrite is Windows-only,
because a POSIX filename may legally contain a literal backslash and rewriting
it there would name a different file — which also keeps Unix output, and so
every golden fixture, byte-identical.

`normalize_separators` is the comparison-side counterpart and is unconditional:
its job is matching a path spelled by a *different* host, not preserving the
local spelling.

No call sites yet; those land with the boundaries they fix.

Refs #246

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The guard classifies paths that come out of agent tool calls, and agents
spell them POSIX-style on every host. Windows has no root without a drive,
so `std::path::absolute` was grafting the process's current one: `/dev/null`
became `C:\dev\null`, which stopped reading as a device and started reading
as a file the guard had to block — `cmd >/dev/null` denied, and
`git fetch origin >/dev/null 2>&1` reclassified from "git remote operation"
to "output redirection to a file". `/etc/passwd` still denied, but the
evidence recorded a path that never existed.

`lexically_absolute` now leaves a rooted-but-prefixless path exactly as
given, and `resolve_path` applies it to the *joined* path so a relative
target under a POSIX-rooted root resolves the same way its allowed roots do.
The stray-write scanner shares the helper for the same reason: resolving one
side with a drive and the other without silently stops the comparison
matching.

`is_non_file_device` now matches by path component, since a resolved
`/dev/fd/1` renders as `fd\1` and a `"fd/"` string prefix would miss it.
Lexical `..` normalization still runs first, so `/dev/../etc/passwd` cannot
launder a write past the device check.

Fixture validation gets the matching treatment: `Path::is_absolute` answers
for the host only, so `/etc/passwd` slipped past it on Windows and
`Path::join`'s root-replacing behavior would have landed the fixture outside
the env. Eval configs are committed and run on every platform, so
`is_absolute_on_any_platform` gives one verdict everywhere — `\etc\passwd`
included, which is absolute on Windows and a legal filename elsewhere.

Windows lib failures: 45 -> 36.

Refs #246

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rmat

`dispatch.json`, `judge-tasks.json`, the manifest, the runbook, the guard's
deny reasons, and the shadow report are all read by agents and by later
pipeline stages, so their path fields are a wire format — but they were built
with `Path::join` and `Display`, which emit the host separator. On Windows a
POSIX-rooted base produced `/work/cond\run.json`, and a descriptor-declared
dir produced genuinely mixed output like `...\home\.agents/skills\different-folder`.

Every such field now goes through `artifact_path`. In `build_dispatch_task`
the rendering happens once up front, so a task's serialized fields and the
prompt text quoting them cannot disagree. Guard deny reasons render their
allowed roots the same way as the scratch hint beside them, so one sentence
never shows the agent a root in one spelling and a directory under it in
another. `canonical_path` loses the verbatim `\?\` prefix `canonicalize`
returns on Windows.

Deliberately left native: the guard hook command line in `sandbox::install`
and harness `exec_template` arguments, which are handed to a process rather
than to a reader.

No golden fixture changed — `artifact_path` is a no-op on Unix, and on Windows
it now produces the bytes the committed fixtures already held. The five golden
tests and the whole `sandbox::decide` deny-verdict group go green.

Windows lib failures: 36 -> 19.

Refs #246

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three comparisons matched a path against a differently-spelled copy of the
same path and silently answered "no". Each failure is quiet by design — the
code reports nothing rather than erroring — so on Windows a real signal simply
stopped appearing.

`prompt_read_failed` searched `args.to_string()`, the *serialized* JSON, for a
raw path. Serializing escapes every Windows separator to `\`, so the text
never contained the path as written: `skipped_prompt_unread` stayed 0 and a
dispatch that never received its instructions was recorded as data. It now
walks the args' string leaves — which also reaches nested shapes like cline's
`files[].path`, the reason the serialized form was searched to begin with —
and compares with separators normalized. This matters beyond Windows now:
`dispatch_prompt_path` is forward-slash wire format while the harness
transcript echoes the agent's own spelling.

`detect_live_source_reads` compared the recorded live directory against the
raw command text, so an arm could read the live skill source and still produce
a clean stray-write report — a contaminated arm presented as comparable data.

`colliding_staged_source` split `discovery_path` on `MAIN_SEPARATOR` to take a
basename. Now that the field is forward-slash wire format, that found no
basename at all on Windows and let a refutation through on exactly the
collision the check exists to block; it splits on either separator.

Windows lib failures: 19 -> 15.

Refs #246

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Twelve failures were the tests' own assumptions rather than the code's.

The guard suites built their marker and manifest JSON with `format!`,
interpolating a raw path. On Windows that embeds `\U`, `\A`, `\T` — none of
them valid JSON escapes — so the marker was malformed, the guard read it as
absent, and every assertion passed through the fail-open path. Ten tests
reported nothing about the guard while looking like they covered it. They now
serialize with `serde_json`, which is also what the production installer does.
The `sandbox::install` byte pins had the mirror-image bug: the *expectation*
interpolated the marker raw into a JSON string the real file escapes.

`canonical_root` returned a verbatim (`\?\`) path, but a child process
reports the plain form as its cwd, so no path the CLI emitted could ever match
one a test joined onto that root. The `tests/run` counterpart is now a shared
`resolved()` helper — it keeps the symlink resolution macOS needs for its
temp dirs and drops only the prefix.

The rest compare against generated artifacts that are now forward-slash wire
format (`discovery_path`, `response_path`, `eval_root`) or against
`cargo package --list`, which prints forward slashes everywhere. Where a
choice existed the comparison was normalized rather than the POSIX literal
rewritten: those spellings are the behavior under test, since agents emit
POSIX paths on any host.

Building the env manifest's `envs[].dir` through `artifact_path` as well —
readers join it against a task's `eval_root`, and the two had drifted into
different spellings.

Windows failures: 68 -> 17, all of them #247's POSIX-executable cluster
(`harness_lint_probe_*` x6, `command_check` x4, `judge_recipe_*` x3,
`execute_with_timeout_*` x2, `run_git_spawn_error_surfaced`,
`execute_round_creates_the_round_output_directory_before_shell_redirection`).
`tests/run` is fully green. No golden fixture changed.

Refs #246

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…erage

Review pass over the branch.

`record_runs.rs` crossed 500 lines, so the prompt-read guard moves to
`record_runs/prompt_read.rs` — a self-contained concern (does the transcript
show the agent's read of its dispatch prompt failing?) beside the existing
`conversation.rs`, matching how this module already splits. 501 -> 411 lines.

Driving a real `eval-magic run` on Windows found two path fields the tests
did not cover, both leaving one logical value spelled two ways in a single
document:

- `conditions.json`'s `skill_path` is echoed into `dispatch.json` beside the
  tasks, which carry the same path — one native, one wire format.
- The runbook's `ingest` line and the judge recipe's `cd` embedded native
  paths in POSIX shell commands, where a backslash is an escape character.
  (The recipes remain POSIX-only; that is #248.)

Generated artifacts now contain no Windows-spelled path at all — verified
against a real run's `dispatch.json`, `conditions.json`, `dispatch-manifest.md`,
and `RUNBOOK.md`. The guard marker stays native by design: it is read back
through `resolve_path`, which compares by component.

Also tightened comments that narrated the fix rather than the code, and moved
a misplaced `crate::` import in `skill_shadow.rs` below the external block.

Refs #246

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fix(windows): make path handling platform-independent
…the OS

Closes #247, whose diagnosis was wrong in two places.

The issue reports the failures as confined to the test suite, and the spawning
code as already portable. Neither held. `/bin/sh` was hardcoded in two
production paths — `harness lint --probe` and multi-turn `dispatch-task` — so
both were broken on every released Windows binary, not merely under test.
`posix_shell()` now resolves a real `sh`: `EVAL_MAGIC_SH`, then `PATH`, then a
Git for Windows install, then `/bin/sh`. It never accepts `bash`, because
`System32\bash.exe` is the WSL launcher and resolves a different filesystem
namespace. That alone fixed 9 of the 17 failures, with no descriptor changes:
the shipped `exec_template`s are POSIX command lines and run unmodified.

The `command_check` cluster was not a POSIX-executable problem either — that
path already chose `cmd /C` on Windows. Two separate bugs hid behind it:

- `Command::arg` escapes a command's embedded quotes as `\"`, which `cmd.exe`
  does not understand, so any eval author's quoted `command_check` silently
  arrived split at its spaces. Now `/S /C` plus `raw_arg`, which hands `cmd`
  the string verbatim. This is user-facing and independent of the tests.
- The batch fixture strings were not equivalent to their POSIX counterparts
  (`echo x>>f` appends CRLF; `echo|set /p=` cannot round-trip a value).

Both dialects are gone. A hidden `__fixture` subcommand now supplies the
predictable child process the suite needs — a chosen exit code, chosen bytes, a
chosen file — in one invocation that `sh -c` and `cmd /C` parse identically.

Tests state the capability they need instead of the OS they tolerate. An
`#[cfg(unix)]` attribute hides a test from compilation and clippy on the other
host and hides the coverage gap with it; `report_skip` prints why a test was
skipped, and `EVAL_MAGIC_REQUIRE_POSIX_TOOLS=1` (set in CI) turns every skip
into a failure so coverage cannot rot. Two capabilities are gated: the POSIX
toolchain the shipped recipes need, and symlink creation, which Windows permits
only under Developer Mode. Where a per-OS difference is genuinely the behavior
under test — signals, path separators — both arms now compile everywhere behind
a runtime `cfg!(windows)`.

`run_git_spawn_error_surfaced` was a third category again: it pinned the POSIX
errno spelling while its own doc comment said the contract was a readable
reason, not a particular one.

`#[cfg(unix)]`/`#[cfg(windows)]` now appears 6 times, all in production code and
none in tests: the per-OS symlink API, the shell selection, and reading a signal
Windows does not have.

Verified on Windows 11, rustc 1.97.1:
- `cargo test --no-fail-fast` — 1122 passing, 0 failing, up from 1069 passing
  and 17 failing.
- `cargo fmt --check` and `cargo clippy --all-targets -- -D warnings` clean.
- `harness lint codex --probe` reaches the shell and reports exit 127
  (`codex` absent) rather than failing to spawn.

Not executed on this host: the three `judge_recipe_*` tests skip without `jq`,
and the three symlink round-trips skip without Developer Mode. Both run on the
Linux job, which sets the enforcement variable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
RENDER_STAND_INS backed only {cwd} and {model_arg}, so render_only_check
reported {guard_args} as unresolved in codex's parallel_command_template and
judge_command_template. The real dispatch path resolves it from
dispatch.guard_args, so both were false failures.

Add the stand-in and cover it two ways: a focused check on the concatenation
shape guarded harnesses use, and a sweep over every embedded descriptor so a
future built-in placeholder without a stand-in fails cargo test rather than
surfacing as a spurious probe failure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fix(windows): resolve a POSIX shell; gate tests on capabilities, not the OS
fix(probe): supply a {guard_args} stand-in to the render-only checks
`run` prepared a correct workspace on a host with no POSIX shell, then printed —
and wrote into RUNBOOK.md and dispatch-manifest.md — commands only a POSIX shell
can execute, with nothing to say a different shell was expected.

State the requirement instead, once, and reuse it: POSIX_TOOLING_REQUIREMENT
feeds the shell-discovery errors, the new `run` preflight, RUNBOOK.md, and
dispatch-manifest.md. It names `jq` alongside the shell because the
parallel-dispatch and judge recipes are `jq` pipelines, and Git for Windows
bundles `sh`, `xargs`, `tr`, and `wc` but not `jq` — so naming only the shell
would point an operator at a setup that still walls out at the judge step.

The preflight warns and continues rather than failing. `run` never dispatches,
so preparing on Windows and dispatching from WSL stays a valid split.

Development carries the same requirement. The scripted-turn tests already spawn
a `#!/bin/sh` stub through the resolved shell with no capability skip, so a
POSIX shell was required in fact but recorded nowhere; the two tolerant
discovery tests now assert it. `jq` and symlink creation remain capability-gated
skips, since Git for Windows supplies neither.

Closes #248

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fix(run): declare a POSIX shell + jq requirement and preflight for it
A staged `.claude/skills/<slug>/SKILL.md` under a deep workspace crosses
Windows' 260-character MAX_PATH, and the task repository's deliberate
configuration isolation (GIT_CONFIG_NOSYSTEM plus an empty
GIT_CONFIG_GLOBAL) discards the core.longpaths an operator set globally.
The runner now writes its own.

The reported failure was the loud band — `git add` aborting the run with
`fatal: unable to stat '...SKILL.md': Filename too long`. Reproducing it
end to end surfaced a quieter one a few characters deeper: git cannot
open the staged directory to enumerate it, so `git add` warns, exits
zero, and commits a baseline that does not contain the skill under test.
The cleanliness check cannot catch that, since git reports nothing about
a file it could not read, so every later diff would be measured against
a baseline missing its subject.

The setting is written twice: to the repository, so the agent under test
and the pipeline's own `run_git` calls inherit it, and transiently on
each invocation, so `git init` is covered before that local config
exists. Initialization failures under a deep root now also name the path
budget, keyed on the measured root length rather than on git's
localizable `Filename too long`.

Fixes #270

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Drop the issue references, which date and point away from the code, and
cut each comment down to the line or item it sits on so none of them
needs a neighbour to make sense.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fix(run): set core.longpaths on runner-owned task repositories
jq's native Windows build opens stdout in text mode, so every `\n` it
writes arrives as `\r\n`. Both shipped recipes read that output as paths,
and neither reader drops the CR: `read -r` keeps it by definition, and
`tr '\n' '\0'` converts only the newline.

The carriage return then rides on the end of every path. The judge recipe
reports `0/N verdicts present` because `[ -s "$response_path" ]` matches
nothing, and the parallel-dispatch recipe hands every task a corrupted
`eval_root`, `dispatch_prompt_path`, and `outputs_dir`. Git Bash with `jq`
is the setup the tooling requirement documents, so this is the supported
Windows path rather than an exotic one.

Pipe each jq call through `tr -d '\r'`: a no-op wherever jq already writes
LF, and `tr` is a declared requirement alongside `jq` itself.

The regression test defines a CRLF-emitting `jq` as a shell function in
front of the recipe rather than shimming `PATH`, so the failure mode is
covered on every host instead of only where a Windows jq is installed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Releases attach a Windows binary and the README documents a PowerShell
installer, but both jobs ran on ubuntu-latest, so every Windows fix so far
was found by hand rather than by the pipeline.

Matrix the test job across ubuntu-latest and windows-latest with
`fail-fast: false`, since a Linux failure cancelling the Windows leg would
lose the result exactly when it is worth having. Clippy earns its place on
both: the `#[cfg(windows)]` arms in `core::fs` and `command_check` are
lint-invisible on Ubuntu.

`EVAL_MAGIC_REQUIRE_POSIX_TOOLS` applies to both runners, so the Windows
host is provisioned for the gated capabilities rather than exempted from
them: `jq`, which Git for Windows does not bundle, and Developer Mode for
symlink creation. Long paths need nothing — task repositories carry their
own `core.longpaths`, and `LongPathsEnabled` stays unset deliberately so
those tests keep proving what a default box does. `EVAL_MAGIC_SH` stays
unset for the same reason: discovering the shell from the Git install root
is what a Windows user hits.

The guard test pins all of it together, because a matrix entry whose
enforcement variable went missing would report green while covering six
fewer tests than it appears to.

Also corrects both contributor docs, which still described two gated
capabilities before long-path staging became the third.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The long-path tests padded from `std::env::temp_dir()`, which can hand back
an 8.3 short name: a GitHub runner's `%TEMP%` lives under `RUNNER~1`, while
git expands that to `runneradmin` before it measures. Three characters no
length computed here could see.

That was enough to move the deepest test from one side of a ceiling to the
other. It passed locally with `.git/config` at 257 and failed on the runner
with the same target at 260, which reads as a Windows-only product failure
and is nothing of the sort.

Canonicalise the base first, so the padding measures the spelling git will
report. Then step the root back to 244 and assert the window it has to sit
in, because git's long-path awareness turns out to be per-operation:
creating `.git/objects/pack` survives well past the budget, `git init`
writing `.git/config` stops exactly at it, and the `git config --local`
that follows gives up two characters earlier still. The old literal sat one
character inside the tightest of those, with nothing recording that it did.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
POSIX gives this away for free: `getcwd` resolves symlinks, so a Unix
process and everything it spawns already agree on how the working
directory is spelled. Windows makes no such promise — it hands back
whatever spelling the cwd was set with — and one directory there has
several valid names: an 8.3 short name, a junction, a `subst` drive, a
redirected profile.

Nothing forced the two sides of the write guard's comparison onto one of
them. Measured, spawning each tool the way a harness is spawned: node's
`process.cwd()`, node's `realpathSync`, and `cmd`'s `cd` all echo the
alias back, while git prints the resolved name for every path it emits.
Both spellings are therefore reachable from inside a single task env, and
`is_under` compares strings. Driving the hook with a short-form root, a
write relative to a long-form cwd is denied, as is an absolute long-form
target under the env — legitimate writes, refused. A genuine escape is
still denied, so the guard never got weaker, only falsely strict. Every
task env is a git repo, which makes `rev-parse --show-toplevel` a
one-step route to the refused spelling.

Resolve once, where the run's roots are derived, rather than at either
end of the comparison: canonicalising only the marker inverts the failure
instead of removing it, denying the same write when the cwd is the alias.
Every path in a `RunContext` now shares one spelling, which makes it a
property of the struct rather than of the one field someone remembered.

Resolution walks up to the deepest ancestor that exists and re-attaches
the rest, because a run names directories before it creates them and the
alias always lives in an ancestor, never in the leaf.

The cli fixtures that built roots with a bare `fs::canonicalize` now use
the helper that strips the verbatim prefix. They had been comparing
against `\?\` paths, a spelling no agent ever produces and one the CLI
no longer emits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fix(run): resolve a run's cwd to one path spelling
ci: run the suite on windows-latest
The generated recipes carry the absolute paths of the host that prepared the
workspace, so the shell that dispatches them has to resolve those same paths.
Git Bash shares the Windows filesystem and does; WSL resolves its own namespace,
where a `C:\...` path names nothing, and nothing in the tree translates between
the two. Guidance that listed Git Bash and WSL side by side — and a preflight
warning inviting an operator to "prepare here and dispatch from a POSIX shell" —
pointed at a split that fails quietly.

POSIX_TOOLING_REQUIREMENT now states the constraint once and names WSL as where
eval-magic runs rather than somewhere to dispatch into; its four Markdown
consumers pick that up, and AFTER_HELP mirrors it by hand for clap.

Also records the support tiers under "Platform support" in the developer
overview: Linux and macOS supported, Windows through Git Bash deprecated with
removal gated on #256, and the Windows-prepare/WSL-dispatch split unsupported.

Before:
  ⚠ no POSIX shell found. ... The workspace and recipes below are still
    correct — prepare here and dispatch them from a POSIX shell.
After:
  ⚠ no POSIX shell found. ... The workspace and recipes below are still
    correct — dispatch them from a POSIX shell on this host.

Verification: cargo fmt --check, cargo clippy --all-targets --all-features
-- -D warnings, cargo test --all-targets (1146 passed, 0 failed). Golden
fixtures re-blessed with GOLDEN_BLESS=1; the diff is one line per fixture.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fix(run): confine dispatch guidance to the preparing host
@slowdini
slowdini merged commit 9ab5cc3 into main Aug 16, 2026
11 checks passed
slowdini added a commit that referenced this pull request Aug 16, 2026
Merge pull request #276 from slowdini/dev
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant