Skip to content
60 changes: 60 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,66 @@ jobs:
if-no-files-found: warn
retention-days: 14

windows-native-suites:
# Regression suites that run to completion in native Git Bash (the list is the evidence for the Windows tier in
# docs/platform-support.md). A suite is added here once it passes natively; one that only passes in WSL2 is not.
runs-on: windows-latest
timeout-minutes: 40
strategy:
fail-fast: false
matrix:
suite:
- test-pmctl-memory
- test-pmctl-pm
- test-pmctl-ship-finish
- test-host-write-parity
- test-host-manifest
- test-lint-model-aliases
- test-pm-prep-snapshot
- test-run-tests-in-wsl
steps:
- uses: actions/checkout@v6
with:
path: native suites/pm-dispatch
- name: Install pinned native tools
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$tools = Join-Path $env:RUNNER_TEMP 'pm-dispatch-native-tools'
New-Item -ItemType Directory -Path $tools -Force | Out-Null
Invoke-WebRequest 'https://github.com/jqlang/jq/releases/download/jq-1.8.1/jq-windows-amd64.exe' -OutFile (Join-Path $tools 'jq.exe')
Invoke-WebRequest 'https://www.sqlite.org/2025/sqlite-tools-win-x64-3500200.zip' -OutFile (Join-Path $tools 'sqlite.zip')
function Assert-NativeToolDigest {
param([string]$Path, [string]$Expected)
$actual = (Get-FileHash -Algorithm SHA256 -LiteralPath $Path).Hash
if ($actual -ne $Expected) { throw "SHA256 mismatch: $Path (got $actual)" }
}
Assert-NativeToolDigest (Join-Path $tools 'jq.exe') '23cb60a1354eed6bcc8d9b9735e8c7b388cd1fdcb75726b93bc299ef22dd9334'
Assert-NativeToolDigest (Join-Path $tools 'sqlite.zip') 'ef1403c72d1f93c89215fb39cb5d5445cdabba44e927eef1e45b02ab78b44429'
Expand-Archive -LiteralPath (Join-Path $tools 'sqlite.zip') -DestinationPath $tools
'C:\Program Files\Git\usr\bin' | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
$tools | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
- name: Run ${{ matrix.suite }} natively
working-directory: native suites/pm-dispatch
shell: pwsh
env:
SUITE: ${{ matrix.suite }}
run: |
$script = Join-Path $env:RUNNER_TEMP 'run-native-suite.sh'
$body = @'
set -euo pipefail
export HOME="$(mktemp -d)"
git config --global user.name ci
git config --global user.email ci@example.com
# The same ref the Linux suites fetch: some cases read origin/main from the checkout itself.
git fetch --depth=1 origin main
timeout --kill-after=15 1800 bash "tests/shell/${SUITE}.sh"
'@
[System.IO.File]::WriteAllText($script, $body.Replace("`r`n", "`n"), [System.Text.UTF8Encoding]::new($false))
$env:PSModulePath = "$(Join-Path $env:WINDIR 'System32\WindowsPowerShell\v1.0\Modules');$env:PSModulePath"
& 'C:\Program Files\Git\bin\bash.exe' --noprofile --norc $script.Replace('\', '/')
exit $LASTEXITCODE

lint-agents:
runs-on: ubuntu-latest
steps:
Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,15 @@ Versions follow [Semantic Versioning](https://semver.org/).

## [Unreleased]

### Fixed

- **Gate: an exhausted executor quota is named as such on a real run.** The quota check read the files an adapter banner names, but a gate run through `pmctl dispatch run` has no banner in the reviewer's log; it has `run: <run-id>` and a `<run-id>.footer` beside the log that names the executor's own stderr and event stream. On a real run that hit the Codex usage limit the failure was reported as an unexplained "transport failure" and retried once. The check now follows the footer (and still reads a banner in a directly dispatched log), so the report carries the executor's reset time and no retry is spent.
- **Memory on native Windows.** Claude Code on Windows names a project's memory directory after the drive-letter path with every character that is not a letter or digit turned into `-` (`C--Users-Jo-Doe-repo`); `find_memory_dir` now also tries that name, so a repository on a native Windows host finds its memory. A native `sqlite3` ends rows with CR LF; the usage reader now strips it, so the last-access day is no longer read as 0 and cards are no longer all ranked oldest. Other platforms are unchanged.

### Changed

- **Native Windows is measured in CI.** A `windows-native-suites` job runs the suites that complete in native Git Bash on a `windows-latest` runner (`test-pmctl-memory`, `test-pmctl-pm`, `test-pmctl-ship-finish` and others), beside the existing smoke job. The three suites that failed only natively now pass there too; their fixtures use the spelling the product records.

- **Windows usage documented, and the native test suite made honest (Windows report items 1 to 6, 10).** `docs/platform-support.md` gains the standard way to drive WSL2 from a Windows session (`ops/diagnostics/wsl-run.sh`: script files and pmctl arguments reach WSL without re-quoting, with `--pmctl-command` and the command's own exit status), where to edit, which permission rules to allow, what a native gate run needs (measured: about 17 minutes, five gates on one branch), and the native test status in four groups. `docs/review-model.md` states why a targeted pass is judged against the whole current subject. Test fixes: cases that need symlinks, POSIX mode bits, special file names or a PATH of links now skip with a reason instead of failing; two tests no longer use `grep -Fi`, which aborts in the GNU grep 3.0 of some Git for Windows installs; path assertions accept the `C:/` spelling the product records; the relocated-host fixture copies `jq-lf.sh` as a real checkout has it.
- **`pmctl gate mutations --preview` gives the mutation checklist before a gate (issue 9 of the Windows report).** qa-tester asks, per test, whether it would fail if the code were wrong in a plausible way, and a weak test used to surface only inside a gate round. The command lists the test files the change touches (`--base` defaults to `HEAD`, so uncommitted and untracked files count) and the mutation classes qa-tester uses: off-by-one or inverted output, first/last element, dropped exit status, removed guard, flipped operator, skipped or reordered call, empty input treated as valid. It is a checklist, not a prediction: it runs no mutants and asks for no test per boundary.
- **`gate.reviewer_executor` in `~/.pm-dispatch/config` pins the PR-gate reviewer (issue 13 of the Windows report).** `gate.reviewer_executor = codex` (or `claude`) makes `pr-gate` use that executor when `--executor` is absent or `auto`, and refuse a different `--executor` with "gate.reviewer_executor pins the reviewer to codex, so --executor claude is refused". A project that fixes "claude implements, codex reviews" can now say so once, and a used-up reviewer quota can no longer move the review to the implementing model unnoticed. `auto` or no key keeps the old behaviour.
Expand Down
1 change: 1 addition & 0 deletions docs/architecture/script-variable-consumers.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,7 @@ OSTYPE OSTYPE runtime/hooks/guard-pm-bash.sh production
OSTYPE OSTYPE runtime/hooks/guard-pm-write.sh production
OSTYPE OSTYPE runtime/hooks/guard-reviewer-write.sh production
OSTYPE OSTYPE runtime/lib/jq-lf.sh production
OSTYPE OSTYPE runtime/lib/memory.sh production
OSTYPE OSTYPE runtime/lib/pmctl-context.sh production
OSTYPE OSTYPE runtime/lib/portable.sh production
OSTYPE OSTYPE tests/bin/run-tests.sh production
Expand Down
17 changes: 12 additions & 5 deletions docs/platform-support.md
Original file line number Diff line number Diff line change
Expand Up @@ -484,11 +484,18 @@ skipped with a stated reason, not silently.
(two tests used it); a hook that read its payload from `/dev/stdin` failed on every prompt when a Windows host
piped it in; several assertions compared the POSIX spelling of a path (`/c/Users/...`) with the `C:/Users/...` form
the product records.
- **Open (native Windows only; cause not fixed).** `test-pmctl-memory` resolve/config/stats cases (the memory
directory is reported `unavailable`), `test-pmctl-pm` `prepare` and `run` cases, and two `test-pmctl-ship-finish`
assessment cases fail natively but pass in WSL2 (measured 2026-11-11), so they are specific to native Windows. The
likely cause is path spelling (`C:/...` versus `/c/...`) in the tests' expectations or in the project key; that is
unconfirmed. Treat WSL2 as the reference result for these files.
- **Fixed after the first measurement (2026-11-11).** The groups that failed only natively had real causes, two of
them product defects. Claude Code on Windows names a project's memory directory after the drive-letter path with
every character that is not a letter or digit turned into `-` (`C:/Users/Jo Doe/repo` is `C--Users-Jo-Doe-repo`);
the lookup only tried the POSIX spelling, so a repository on a native Windows host never found its memory. A native
`sqlite3` ends every row with CR LF, which left a CR on the last-access day and ranked every card as the oldest.
The rest was tests: fixtures and expectations used `/c/Users/...` where the product records `C:/Users/...` (a
project key hashes the lower-case-drive form), and a `bash -c` shell in the ship-finish suite skipped the jq LF
shim the product entry loads. `test-pmctl-memory`, `test-pmctl-pm` and `test-pmctl-ship-finish` now pass natively
as well as in WSL2.
- **Evidence in CI.** The `windows-native-suites` job in `.github/workflows/lint.yml` runs the suites that complete
in native Git Bash on a `windows-latest` runner, one job per suite, beside the bounded `windows-native-smoke`. A
suite is added to its matrix once it passes natively; a suite that passes only in WSL2 is not.

---

Expand Down
26 changes: 20 additions & 6 deletions runtime/lib/gate-protocol.sh
Original file line number Diff line number Diff line change
Expand Up @@ -90,18 +90,32 @@ gate_reviewer_sandbox_unavailable_signature() {
# review to another executor, so the useful report is the executor's own reset hint and nothing else.
#
# The adapter writes the executor's own stderr and event stream to per-run files next to its trace, not to the
# reviewer's dispatch log; the log only carries the adapter's banner, whose `trace: <path>.jsonl` line names them.
# reviewer's dispatch log. Through `pmctl dispatch run` (a real gate run, measured) the log's `run: <run-id>` line leads
# to them: pmctl keeps the adapter's stdout footer as `<run-id>.footer` beside the log, and its `trace:` and `stderr:`
# lines name them. With direct dispatch the adapter's own output is the log and names its trace itself.
# gate_reviewer_quota_evidence_files <dispatch_log>
# Prints, one per line, the files that can hold the executor's usage-limit text for the reviewer that wrote
# <dispatch_log>: the log itself, then the `.stderr` and `.jsonl` of the trace its banner names (when readable).
gate_reviewer_quota_evidence_files() {
local log="$1" trace
local log="$1" trace="" err="" run_id="" footer=""
[[ -n "$log" && -r "$log" ]] || return 0
printf '%s\n' "$log"
trace="$(sed -n 's/^[[:space:]]*trace:[[:space:]]*//p' -- "$log" 2>/dev/null | head -1 | tr -d '\r')"
[[ "$trace" == *.jsonl ]] || return 0
[[ -r "${trace%.jsonl}.stderr" ]] && printf '%s\n' "${trace%.jsonl}.stderr"
[[ -r "$trace" ]] && printf '%s\n' "$trace"
# A real gate run: `pmctl dispatch run` prints "run: <run-id> ..." and keeps the adapter's stdout footer as
# <run-id>.footer next to this log; its trace: and stderr: lines name the files that hold the executor's own output.
run_id="$(sed -n 's/^run: \(run-[0-9A-Za-z-]*\).*/\1/p' -- "$log" 2>/dev/null | head -1 | tr -d '\r')"
footer="${log%/*}/$run_id.footer"
if [[ -n "$run_id" && -r "$footer" ]]; then
err="$(sed -n 's/^stderr:[[:space:]]*//p' -- "$footer" 2>/dev/null | head -1 | tr -d '\r')"
trace="$(sed -n 's/^trace:[[:space:]]*//p' -- "$footer" 2>/dev/null | head -1 | tr -d '\r')"
fi
# Direct dispatch (no pmctl in between): the adapter's own output is the log, and it names its trace in a banner
# (` trace: <path>.jsonl`) and in its stdout footer.
if [[ -z "$trace" ]]; then
trace="$(sed -n 's/^[[:space:]]*trace:[[:space:]]*//p' -- "$log" 2>/dev/null | head -1 | tr -d '\r')"
fi
if [[ -z "$err" && "$trace" == *.jsonl ]]; then err="${trace%.jsonl}.stderr"; fi
if [[ -n "$err" && -r "$err" ]]; then printf '%s\n' "$err"; fi
if [[ "$trace" == *.jsonl && -r "$trace" ]]; then printf '%s\n' "$trace"; fi
return 0
}

Expand Down
7 changes: 6 additions & 1 deletion runtime/lib/memory-dir.sh
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,19 @@ find_memory_dir() {
printf '%s' "$routing_dir"; return 0
fi
local config_dir="${2:-${CLAUDE_CONFIG_DIR:-${HOME}/.claude}}"
local projects_dir current candidate parent
local projects_dir current candidate parent win_encoded
projects_dir="$config_dir/projects"
current="${cwd%/}"
while true; do
candidate="$projects_dir/$(encode_path "$current")/memory"
if [[ -d "$candidate" ]]; then
printf '%s' "$candidate"; return 0
fi
# The name Claude Code gives a project on native Windows (see _memory_windows_encoded_var in memory.sh).
_memory_windows_encoded_var win_encoded "$current"
if [[ -n "$win_encoded" && -d "$projects_dir/$win_encoded/memory" ]]; then
printf '%s' "$projects_dir/$win_encoded/memory"; return 0
fi
parent="$(dirname "$current")"
[[ "$parent" != "$current" ]] || return 1
current="$parent"
Expand Down
43 changes: 40 additions & 3 deletions runtime/lib/memory.sh
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,24 @@ _memory_encode_path_var() {
printf -v "$1" '%s' "${p//\//-}"
}

# _memory_windows_encoded_var <out-var> <path>: the directory name Claude Code on Windows gives a project whose
# path is /c/... (Git Bash) or C:/... : "C:/Users/Jo Doe/repo" -> "C--Users-Jo-Doe-repo". Empty for any other path
# and on other platforms, so the POSIX lookup is unchanged.
_memory_windows_encoded_var() {
local __mwe_p="$2" __mwe_letter __mwe_rest
printf -v "$1" '%s' ""
case "${OSTYPE:-}" in msys*|cygwin*) ;; *) return 0 ;; esac
if [[ "$__mwe_p" =~ ^/([A-Za-z])(/.*)?$ ]]; then
__mwe_letter="${BASH_REMATCH[1]}"; __mwe_rest="${BASH_REMATCH[2]}"
elif [[ "$__mwe_p" =~ ^([A-Za-z]):(/.*)?$ ]]; then
__mwe_letter="${BASH_REMATCH[1]}"; __mwe_rest="${BASH_REMATCH[2]}"
else
return 0
fi
__mwe_p="${__mwe_letter^^}:${__mwe_rest}"
printf -v "$1" '%s' "${__mwe_p//[^abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789]/-}"
}

encode_path() {
local encoded
_memory_encode_path_var encoded "$1"
Expand Down Expand Up @@ -88,6 +106,16 @@ find_memory_dir() {
printf '%s' "$candidate"
return 0
fi
# Claude Code on Windows names the directory after the drive-letter path with every character that is not a
# letter or digit turned into "-": C:\Users\Jo Doe\repo is C--Users-Jo-Doe-repo, not -c-Users-Jo Doe-repo.
_memory_windows_encoded_var encoded "$current"
if [[ -n "$encoded" ]]; then
candidate="$projects_dir/$encoded/memory"
if [[ -d "$candidate" ]]; then
printf '%s' "$candidate"
return 0
fi
fi
_memory_parent_var parent "$current"
[[ "$parent" != "$current" ]] || return 1
current="$parent"
Expand Down Expand Up @@ -187,9 +215,18 @@ memory_usage_read() {
# escape it here, at the query that crosses into the shared
# tab-separated boundary, so every row — regardless of which code
# version inserted it — gets the same escaping with no migration step.
sqlite3 -cmd ".timeout $timeout_ms" -separator $'\t' "$store" \
"SELECT REPLACE(REPLACE(REPLACE(card_relpath, '\\', '\\\\'), CHAR(9), '\\t'), CHAR(10), '\\n'), access_count, last_access_day FROM card_usage ORDER BY card_relpath;" \
2>/dev/null || return 1
local _sql="SELECT REPLACE(REPLACE(REPLACE(card_relpath, '\\', '\\\\'), CHAR(9), '\\t'), CHAR(10), '\\n'), access_count, last_access_day FROM card_usage ORDER BY card_relpath;"
case "${OSTYPE:-}" in
msys*|cygwin*)
# A native Windows sqlite3 ends every row with CR LF; the CR would stay on the last column and make the
# day number read as 0 (the oldest recency bucket). A file name cannot hold a CR on Windows.
sqlite3 -cmd ".timeout $timeout_ms" -separator $'\t' "$store" "$_sql" 2>/dev/null | tr -d '\r'
[[ "${PIPESTATUS[0]}" -eq 0 ]] || return 1
;;
*)
sqlite3 -cmd ".timeout $timeout_ms" -separator $'\t' "$store" "$_sql" 2>/dev/null || return 1
;;
esac
return 0
fi
legacy="${store%.sqlite3}.tsv"
Expand Down
15 changes: 12 additions & 3 deletions tests/lib/test-memory-config-fixtures.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,24 @@
# Source from tests after their temporary root is initialized.

memory_fixture_project_key() {
local repo="$1" root common_dir
local repo="$1" root common_dir in_git=0
common_dir="$(git -C "$repo" rev-parse --git-common-dir 2>/dev/null || true)"
if [[ "$common_dir" == /* ]]; then
if [[ "$common_dir" == /* || "$common_dir" =~ ^[A-Za-z]:[/\\] ]]; then
# Linked worktrees share the primary checkout's project identity.
root="$(dirname "$common_dir")"
in_git=1
elif root="$(git -C "$repo" rev-parse --show-toplevel 2>/dev/null)" && [[ -n "$root" ]]; then
in_git=1
else
root="$(git -C "$repo" rev-parse --show-toplevel 2>/dev/null || (cd "$repo" && pwd -P))"
root="$(cd "$repo" && pwd -P)"
fi
root="$(cd "$root" 2>/dev/null && pwd -P || printf '%s' "$root")"
# Inside a repository the product hashes one canonical spelling on native Windows: the drive-letter path with a
# lower-case drive (c:/Users/...). A directory outside any repository is hashed as the shell spells it (/c/Users/...).
if [[ "$in_git" -eq 1 && ( "${OSTYPE:-}" == msys* || "${OSTYPE:-}" == cygwin* ) ]]; then
root="$(cygpath -ml "$root")"
root="$(printf '%s' "${root:0:1}" | tr '[:upper:]' '[:lower:]')${root:1}"
fi
if command -v sha1sum >/dev/null 2>&1; then
printf '%s\n' "$root" | sha1sum | awk '{print $1}'
else
Expand Down
29 changes: 29 additions & 0 deletions tests/shell/test-gate-protocol.sh
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,34 @@ case_outcome_matches_legacy_pr_gate_records() {
fi
}

# Behavior: the executor's usage-limit text is found for a reviewer run through `pmctl dispatch run`, laid out as
# measured on a real gate that hit the Codex quota: the per-reviewer log holds only pmctl's "run: <id> ... failed" line,
# and the executor's own output sits in the files the run's <id>.footer names (the .jsonl event stream carries the line,
# with the reset time). A log with no such text, and a footer whose files hold none, report nothing.
case_quota_signature_follows_the_run_footer() {
local name="quota signature: found through the run footer next to a pmctl dispatch log"
should_run "$name" || return 0
local dir="$tmp_root/quota-footer" trace_dir got
trace_dir="$dir/.agent-trace"
mkdir -p "$trace_dir"
printf '%s\n' 'pmctl dispatch run: adapter=codex' 'run: run-20261111T111811Z-bc7183 state: failed exit: 1' \
'adapter exited before post-verify (exit 1)' > "$trace_dir/gate-1-critic.log"
printf '%s\n' '---' "trace: $trace_dir/codex-1.jsonl" "stderr: $trace_dir/codex-1.stderr" 'exit: 1' '---' \
> "$trace_dir/run-20261111T111811Z-bc7183.footer"
printf '%s\n' '[2026-11-11T20:19:34+09:00] codex-dispatch starting' > "$trace_dir/codex-1.stderr"
printf '%s\n' '{"type":"turn.started"}' \
'{"type":"error","message":"You'"'"'ve hit your usage limit. Try again at Nov 12th, 2026 11:20 AM."}' > "$trace_dir/codex-1.jsonl"
got="$(gate_reviewer_quota_exhausted_signature "$trace_dir/gate-1-critic.log" || true)"
local none_ok=1
: > "$trace_dir/codex-1.jsonl"
gate_reviewer_quota_exhausted_signature "$trace_dir/gate-1-critic.log" >/dev/null && none_ok=0
if [[ "$got" == *"Nov 12th, 2026 11:20 AM"* && "$none_ok" -eq 1 ]]; then
pass "$name"
else
fail "$name" "got=$got none_ok=$none_ok"
fi
}

case_record_emits_full_v1_line
case_record_named_reviewer_is_string_empty_is_null
case_record_append_not_overwrite
Expand All @@ -305,5 +333,6 @@ case_outcome_attempt2_exhausted_aborts
case_outcome_record_write_failure_returns_2_no_token
case_outcome_role_is_parametrised
case_outcome_matches_legacy_pr_gate_records
case_quota_signature_follows_the_run_footer

th_summary
Loading
Loading