diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 452c2a75..f1452728 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -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: diff --git a/CHANGELOG.md b/CHANGELOG.md index ab66fcfd..53899d9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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: ` and a `.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. diff --git a/docs/architecture/script-variable-consumers.tsv b/docs/architecture/script-variable-consumers.tsv index b364e720..c6566245 100644 --- a/docs/architecture/script-variable-consumers.tsv +++ b/docs/architecture/script-variable-consumers.tsv @@ -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 diff --git a/docs/platform-support.md b/docs/platform-support.md index 0fa7d255..ed6f42df 100644 --- a/docs/platform-support.md +++ b/docs/platform-support.md @@ -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. --- diff --git a/runtime/lib/gate-protocol.sh b/runtime/lib/gate-protocol.sh index 4988e600..1cbbcbea 100644 --- a/runtime/lib/gate-protocol.sh +++ b/runtime/lib/gate-protocol.sh @@ -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: .jsonl` line names them. +# reviewer's dispatch log. Through `pmctl dispatch run` (a real gate run, measured) the log's `run: ` line leads +# to them: pmctl keeps the adapter's stdout footer as `.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 # Prints, one per line, the files that can hold the executor's usage-limit text for the reviewer that wrote # : 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: ..." and keeps the adapter's stdout footer as + # .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: .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 } diff --git a/runtime/lib/memory-dir.sh b/runtime/lib/memory-dir.sh index bac8119f..7ab72776 100644 --- a/runtime/lib/memory-dir.sh +++ b/runtime/lib/memory-dir.sh @@ -36,7 +36,7 @@ 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 @@ -44,6 +44,11 @@ find_memory_dir() { 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" diff --git a/runtime/lib/memory.sh b/runtime/lib/memory.sh index 4d622818..fc77aa4f 100644 --- a/runtime/lib/memory.sh +++ b/runtime/lib/memory.sh @@ -10,6 +10,24 @@ _memory_encode_path_var() { printf -v "$1" '%s' "${p//\//-}" } +# _memory_windows_encoded_var : 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" @@ -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" @@ -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" diff --git a/tests/lib/test-memory-config-fixtures.sh b/tests/lib/test-memory-config-fixtures.sh index 044c84ac..fd185069 100644 --- a/tests/lib/test-memory-config-fixtures.sh +++ b/tests/lib/test-memory-config-fixtures.sh @@ -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 diff --git a/tests/shell/test-gate-protocol.sh b/tests/shell/test-gate-protocol.sh index 924754bb..dce5a852 100755 --- a/tests/shell/test-gate-protocol.sh +++ b/tests/shell/test-gate-protocol.sh @@ -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: ... failed" line, +# and the executor's own output sits in the files the run's .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 @@ -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 diff --git a/tests/shell/test-host-write-parity.sh b/tests/shell/test-host-write-parity.sh index afabf6d7..f85b78c0 100755 --- a/tests/shell/test-host-write-parity.sh +++ b/tests/shell/test-host-write-parity.sh @@ -15,6 +15,9 @@ REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" # shellcheck source=runtime/lib/host-manifest.sh . "$REPO_ROOT/runtime/lib/host-manifest.sh" th_init "test-host-write-parity" "$@" +# The product records physical paths; on native Windows the scratch root may be a /tmp alias of one (a CI runner's +# default), and these cases compare the two as strings, so work under the physical spelling. +tmp_root="$(cd "$tmp_root" && pwd -P)" claude_fingerprint() { local root="$1" path rel digest diff --git a/tests/shell/test-pmctl-memory.sh b/tests/shell/test-pmctl-memory.sh index ecda7708..03a8df23 100755 --- a/tests/shell/test-pmctl-memory.sh +++ b/tests/shell/test-pmctl-memory.sh @@ -23,6 +23,9 @@ PMCTL="$REPO_ROOT/cli/pmctl" # shellcheck disable=SC1091 . "$SCRIPT_DIR/../lib/test-memory-config-fixtures.sh" th_init "$@" +# The product reports physical paths. On native Windows the scratch root may be a /tmp alias (or an 8.3 short name) of one, +# and the cases compare the two as strings, so work under the physical spelling from here on. +tmp_root="$(cd "$tmp_root" && pwd -P)" # Operator config is external state. Every case starts isolated and opts into a # fixture config explicitly when exercising project-scoped selection. @@ -47,7 +50,17 @@ _LIVE_MEM_DIR="$(find_memory_dir "$REPO_ROOT" 2>/dev/null || true)" # Encode a repo path into the CLAUDE config "projects" dir key. # Mirrors runtime/lib/memory.sh encode_path: "/a/b" → "-a-b". +# The spelling a native Windows git and the product record for a path: C:/Users/... for /c/Users/...; unchanged elsewhere. +native_path() { + if [[ "${OSTYPE:-}" == msys* || "${OSTYPE:-}" == cygwin* ]]; then cygpath -ml "$1"; else printf '%s' "$1"; fi +} + mem_encode_path() { + # Claude Code on Windows: every character that is not a letter or digit becomes "-" (C:/Users/Jo Doe -> C--Users-Jo-Doe). + if [[ "${OSTYPE:-}" == msys* || "${OSTYPE:-}" == cygwin* ]]; then + printf '%s' "$(cygpath -ml "$1")" | sed 's/[^A-Za-z0-9]/-/g' + return + fi printf '%s' "-${1#/}" | tr '/' '-' } @@ -122,7 +135,7 @@ case_memory_resolve_env_contract() { mkdir -p "$repo" "$mdir" git -C "$repo" init -q PM_MEMORY_DIR="$mdir" "$PMCTL" memory resolve --repo-root "$repo" --json > "$out" 2>/dev/null || status=$? - if [[ "$status" -eq 0 ]] && jq -e --arg repo "$repo" --arg mdir "$mdir" \ + if [[ "$status" -eq 0 ]] && jq -e --arg repo "$(native_path "$repo")" --arg mdir "$mdir" \ '.status == "resolved" and .repo_root == $repo and .memory_dir == $mdir and .resolution_source == "env" and (.project_key | length) > 0 and .readable == true and .writable == true' "$out" >/dev/null; then pass "$name" else @@ -326,6 +339,34 @@ case_memory_resolve_legacy_compatibility() { fi } +# Behavior: on native Windows both discovery helpers (the hook one in memory.sh and the installer/migrator one in +# memory-dir.sh) find a project whose only memory directory carries the name Claude Code gives it there +# (C--Users-Jo-Doe-repo), with no override set. Elsewhere that name does not exist, so the case does not apply. +case_memory_discovery_finds_the_native_windows_project_name() { + local name="memory discovery: both helpers find the native Windows project directory name" + should_run "$name" || return 0 + if [[ "${OSTYPE:-}" != msys* && "${OSTYPE:-}" != cygwin* ]]; then + skip "$name" "applies to native Windows only: the project directory name is the Windows spelling" + return 0 + fi + local repo="$tmp_root/winslug repo" cfg="$tmp_root/winslug-cfg" slug want hook installer + mkdir -p "$repo" "$cfg" + # The helpers get the physical /c/... spelling, as a hook does: a /tmp alias is not a drive path. + repo="$(cd "$repo" && pwd -P)"; cfg="$(cd "$cfg" && pwd -P)" + slug="$(mem_encode_path "$repo")" + want="$cfg/projects/$slug/memory" + mkdir -p "$want" + # shellcheck disable=SC1091 # sourced in a subshell on purpose; the path is built at run time + hook="$(unset PM_MEMORY_DIR PM_CFG_MEMORY_DIR CLAUDE_ROUTING_LOG_DIR; . "$REPO_ROOT/runtime/lib/memory.sh"; find_memory_dir "$repo" "$cfg")" || hook="failed" + # shellcheck disable=SC1091 # same + installer="$(unset PM_MEMORY_DIR PM_CFG_MEMORY_DIR CLAUDE_ROUTING_LOG_DIR; . "$REPO_ROOT/runtime/lib/memory-dir.sh"; find_memory_dir "$repo" "$cfg")" || installer="failed" + if [[ "$slug" =~ ^[A-Za-z]--[A-Za-z0-9-]+$ && "$hook" == "$want" && "$installer" == "$want" ]]; then + pass "$name" + else + fail "$name" "slug=$slug hook=$hook installer=$installer want=$want" + fi +} + # Behavior: strict resolution honors dispatch.memory_dir as the shared cross-host location. # Steps: write an isolated config, resolve an existing directory, and assert source=config. case_memory_resolve_config_contract() { @@ -2981,6 +3022,8 @@ case_memory_usage_tsv_prefix_store_survives_upgrade() { case_memory_stats_combined_control_path_is_measured() { local name="pmctl memory stats: tab and backslash path is measured" should_run "$name" || return 0 + # Uses a file name with a tab and a backslash, which Windows file names cannot hold. + th_require_special_filenames "$name" || return 0 # No newline here: a raw newline inside a MEMORY.md link destination would # split it across physical lines, which is an index-authoring format @@ -3735,6 +3778,8 @@ case_memory_applied_record_refuses_race_swap_before_open() { case_memory_applied_record_refuses_dangling_symlink_and_hardlink() { local name="memory_applied_record: refuses a dangling-symlink target (no creation) and a hard-linked sentinel (flock and mkdir-lock paths)" should_run "$name" || return 0 + # Needs a real symlink. + th_require_symlinks "$name" || return 0 local mode for mode in flock mkdir; do @@ -4482,6 +4527,7 @@ case_project_key_is_the_hash_of_the_canonical_main_root case_memory_resolve_invalid_explicit_no_fallback case_memory_resolve_legacy_compatibility case_memory_resolve_config_contract +case_memory_discovery_finds_the_native_windows_project_name case_memory_resolve_env_outranks_config case_memory_resolve_invalid_config_no_fallback case_memory_resolve_rejects_relative_explicit_paths diff --git a/tests/shell/test-pmctl-pm.sh b/tests/shell/test-pmctl-pm.sh index a8fa2e1a..cb3637e4 100755 --- a/tests/shell/test-pmctl-pm.sh +++ b/tests/shell/test-pmctl-pm.sh @@ -5,10 +5,17 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" PMCTL="$REPO_ROOT/cli/pmctl" +# The spelling native Windows git and the product record for a path (C:/Users/... for /c/Users/...); unchanged elsewhere. +native_path() { + if [[ "${OSTYPE:-}" == msys* || "${OSTYPE:-}" == cygwin* ]]; then cygpath -ml "$1"; else printf '%s' "$1"; fi +} # shellcheck source=tests/lib/test-harness.sh . "$SCRIPT_DIR/../lib/test-harness.sh" th_init "$@" +# The product reports physical paths. On native Windows the scratch root may be a /tmp alias (or an 8.3 short name) of one, +# and the cases compare the two as strings, so work under the physical spelling from here on. +tmp_root="$(cd "$tmp_root" && pwd -P)" # Operator memory/config is external state. Contract-only prepare cases run # against this checkout, so isolate both project-scoped config and legacy @@ -32,7 +39,7 @@ case_prepare_emits_batch_contract() { local out code=0 snapshot out="$("$PMCTL" pm prepare --cd "$REPO_ROOT" --request 'implement CC-473' --json 2>/dev/null)" || code=$? if [[ "$code" -eq 0 ]] \ - && jq -e --arg wd "$REPO_ROOT" '.mode == "batch-only" and .working_dir == $wd and .focus_tickets == ["CC-473"] and .snapshot_status == "created" and .handover_required == true' <<<"$out" >/dev/null; then + && jq -e --arg wd "$(native_path "$REPO_ROOT")" '.mode == "batch-only" and .working_dir == $wd and .focus_tickets == ["CC-473"] and .snapshot_status == "created" and .handover_required == true' <<<"$out" >/dev/null; then snapshot="$(jq -r '.snapshot_file' <<<"$out")" [[ -f "$snapshot" ]] && rm -f "$snapshot" && pass "$name" || fail "$name" "snapshot_file was not created" else @@ -50,7 +57,7 @@ case_prepare_emits_human_contract() { snapshot="$(printf '%s\n' "$out" | sed -n 's/^snapshot_file: //p')" if [[ "$code" -eq 0 ]] \ && [[ "$out" == *$'mode: batch-only\n'* ]] \ - && [[ "$out" == *"working_dir: $REPO_ROOT"* ]] \ + && [[ "$out" == *"working_dir: $(native_path "$REPO_ROOT")"* ]] \ && [[ "$out" == *"focus_tickets: CC-473"* ]] \ && [[ "$out" == *"snapshot_status: created"* ]] \ && [[ "$out" == *"next: author a complete dispatch_handover_v1 brief, then run pmctl pm run"* ]] \ @@ -69,7 +76,7 @@ case_prepare_defaults_to_caller_git_root() { should_run "$name" || return 0 local out code=0 snapshot out="$(cd "$REPO_ROOT" && "$PMCTL" pm prepare --request 'default cwd CC-473' --json 2>/dev/null)" || code=$? - if [[ "$code" -eq 0 ]] && jq -e --arg wd "$REPO_ROOT" '.working_dir == $wd' <<<"$out" >/dev/null; then + if [[ "$code" -eq 0 ]] && jq -e --arg wd "$(native_path "$REPO_ROOT")" '.working_dir == $wd' <<<"$out" >/dev/null; then snapshot="$(jq -r '.snapshot_file' <<<"$out")" [[ -f "$snapshot" ]] && rm -f "$snapshot" pass "$name" @@ -110,7 +117,7 @@ case_prepare_repo_context_marker_round_trip() { out="$(PM_DISPATCH_CONTEXT_AUTOREFRESH=1 "$PMCTL" pm prepare --cd "$work" --request 'cc484prepareroundtrip' --json 2>/dev/null)" || code=$? snapshot="$(jq -r '.snapshot_file // empty' <<<"$out" 2>/dev/null || true)" [[ -n "$snapshot" && -f "$snapshot" ]] && rm -f "$snapshot" - if [[ "$code" -ne 0 ]] || ! jq -e --arg repo "$work" --arg db "$work/.pm-dispatch/ctx/context.db" \ + if [[ "$code" -ne 0 ]] || ! jq -e --arg repo "$(native_path "$work")" --arg db "$(native_path "$work")/.pm-dispatch/ctx/context.db" \ '.repo_context.resolved_repo_root == $repo and .repo_context.db_path == $db and .repo_context.freshness == "fresh" and (.repo_context.refresh_status == "built" or .repo_context.refresh_status == "refreshed")' <<<"$out" >/dev/null; then fail "$name" "first prepare did not report fresh canonical context: code=$code out=$out"; return 0 fi @@ -503,7 +510,7 @@ case_run_uses_validate_detached_wait() { if [[ "$code" -eq 0 ]] \ && [[ "$seen_validate" == *"/tmp/brief-test.md"* ]] \ && [[ "$seen_launch" == *"--lifecycle detached"* ]] \ - && [[ "$seen_wait" == "wait:run-test-473 --cd $REPO_ROOT --timeout 61" ]] \ + && [[ "$seen_wait" == "wait:run-test-473 --cd $(native_path "$REPO_ROOT") --timeout 61" ]] \ && jq -e '.run_id == "run-test-473" and .wait_exit_code == 0' "$out" >/dev/null; then pass "$name" else diff --git a/tests/shell/test-pmctl-ship-finish.sh b/tests/shell/test-pmctl-ship-finish.sh index fda4b963..979c56ac 100755 --- a/tests/shell/test-pmctl-ship-finish.sh +++ b/tests/shell/test-pmctl-ship-finish.sh @@ -23,6 +23,10 @@ PMCTL="$REPO_ROOT/cli/pmctl" . "$REPO_ROOT/runtime/lib/gate-subject.sh" th_init "$@" +# Cases below start `bash -c` shells that source the gate libraries directly; the product entry loads the jq LF +# shim first, and a native Windows jq would otherwise leave a CR on each value. BASH_ENV loads it in those shells too. +export BASH_ENV="$REPO_ROOT/runtime/lib/jq-lf.sh" + # Isolate XDG_RUNTIME_DIR for this suite's detached dispatch launches -- # same pattern as test-dispatch-lifecycle.sh/test-gate-lifecycle.sh/ # test-pmctl-gate.sh. Without this, detached-launch's key_file (used to