From 8097a3059ba77bf8db101fc5177f386bf84ac37e Mon Sep 17 00:00:00 2001 From: Jason Irish Date: Tue, 1 Sep 2026 11:27:06 -0500 Subject: [PATCH 1/3] feat(#67): add Windows and macOS legs to CI .github/workflows/ci.yml's shell job ran on ubuntu-latest only, despite all five hooks being pure POSIX sh and the README making no OS-specific claim. Claude Code executes a command hook under Git Bash on native Windows, a meaningfully different environment (NTFS permission semantics, no default symlink privilege) than Ubuntu's bash - this converts that unmade platform claim into a made, tested one, matching the coverage a competing plugin (adrrr/persistent-handoff) already has. Changes needed to make tests/run.sh actually pass on Windows/Git Bash: - Three permission-based assertions stage a POSIX mode bit (000 unreadable, 555 read-only directory) that NTFS's chmod emulation can't reliably enforce the same way ext4/APFS do. Guarded with a new is_windows() helper (checks MSYSTEM, which only Git Bash/MSYS2 sets) alongside the existing root-bypasses-permissions skip, reporting how many were skipped in the final summary rather than the pass count silently reading lower with no explanation. - The missing-jq test's PATH stub swapped ln -sf for a tiny exec wrapper script per tool: a symlink needs a privilege Windows doesn't grant by default, and (caught by testing this locally before pushing) a `cp` of the real binary is actively worse - a macOS Homebrew binary can resolve its shared libraries via an @executable_path-relative reference, which hangs or crashes once relocated to the stub directory. A `#!/bin/sh -c 'exec "$@"'` wrapper needs no privilege beyond writing a text file and never touches the real binary's own location. - Added .gitattributes forcing LF on *.sh: GitHub's Windows runner image defaults core.autocrlf to true, and a CRLF-mangled shebang or an embedded \r mid-line breaks a shell script's execution outright. The one chmod-based test NOT guarded (444 read-only file, blocking a write) is left as-is - Windows' read-only DOS attribute plausibly does block writes the way NTFS can't block reads, so this is a real test of real Windows behavior, not a guess dressed as one. Live CI will confirm or correct that. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01UXL2ffVDsAp4P7387Nr18G --- .gitattributes | 5 +++++ .github/workflows/ci.yml | 35 +++++++++++++++++++++++++++++------ CHANGELOG.md | 8 ++++++++ tests/run.sh | 38 ++++++++++++++++++++++++++++++++++---- 4 files changed, 76 insertions(+), 10 deletions(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..916873d --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +# Every hook is a shebang script executed directly - a CRLF-mangled shebang +# or an embedded \r mid-line breaks execution outright on Windows, where +# GitHub's hosted runner defaults core.autocrlf to true on checkout (issue +# #67). Force LF regardless of that setting or a contributor's local config. +*.sh text eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6119c6d..157e36d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,21 +7,44 @@ on: jobs: shell: - runs-on: ubuntu-latest + name: shell (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + defaults: + run: + # Claude Code executes a command hook under Git Bash on native + # Windows (issue #67) - this is the leg that answers "does it work + # there", not just "does it parse as POSIX sh". + shell: bash steps: - uses: actions/checkout@v4 - - name: Install shellcheck and jq - run: sudo apt-get update && sudo apt-get install -y shellcheck jq - - name: Lint hooks and tests + # jq ships preinstalled on all three hosted runner images; shellcheck + # does not on Windows. Checked rather than assumed, same discipline as + # every hook's own jq-presence check. + - name: Ensure jq + run: command -v jq >/dev/null 2>&1 || { echo "jq missing on $RUNNER_OS" >&2; exit 1; } + # Static analysis of file content - the result doesn't depend on which + # OS runs it, so one leg is enough. Kept on Linux, where it's already + # preinstalled, rather than installing it fresh on the other two. + - name: Install shellcheck (Linux) + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y shellcheck + - name: Lint hooks and tests (Linux) + if: runner.os == 'Linux' run: shellcheck -s sh hooks/*.sh tests/*.sh - - name: Validate JSON manifests + - name: Validate JSON manifests (Linux) + if: runner.os == 'Linux' run: | jq -e '.hooks | keys' hooks/hooks.json >/dev/null jq -e '.name and .version' .claude-plugin/plugin.json >/dev/null jq -e '.plugins' .claude-plugin/marketplace.json >/dev/null jq -e '.name and .version' .codex-plugin/plugin.json >/dev/null jq -e '.plugins' .agents/plugins/marketplace.json >/dev/null - - name: Check plugin version agreement + - name: Check plugin version agreement (Linux) + if: runner.os == 'Linux' run: | c=$(jq -r .version .claude-plugin/plugin.json) x=$(jq -r .version .codex-plugin/plugin.json) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f604ff..a92c7c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ All notable changes to throughline are documented here. Format loosely follows ## [Unreleased] ### Added +- CI now runs the shell test suite on `windows-latest` and `macos-latest` in + addition to `ubuntu-latest` (issue #67) - previously an unmade platform + claim for hooks that are pure POSIX `sh`. A `.gitattributes` forces LF line + endings on `*.sh` regardless of the Windows runner's `core.autocrlf` + default, and a handful of permission-based test assertions that stage a + POSIX mode bit (000/444/555) are skipped on Windows/NTFS, same as the + existing root-bypasses-permissions skip, with the skip count reported in + the final summary. - **Stale-handoff warning** (issue #66): `SessionStart` now compares `HANDOFF.md`'s `Last Updated` date against the branch's latest commit and warns past a 14-day gap. Every handoff-file design shares this blind spot diff --git a/tests/run.sh b/tests/run.sh index 3a6936e..984c985 100644 --- a/tests/run.sh +++ b/tests/run.sh @@ -42,6 +42,18 @@ ROOT=$(unset CDPATH; cd -- "$(dirname -- "$0")/.." && pwd) H="$ROOT/hooks" PASS=0 FAIL=0 +SKIPPED_WINDOWS=0 + +# Git Bash / MSYS2 sets MSYSTEM (MINGW64, UCRT64, MSYS, ...); nothing else +# realistically sets it (issue #67). A few permission-based assertions below +# stage a POSIX mode bit (000/444/555) that NTFS's chmod emulation cannot +# reliably enforce the same way ext4/APFS do - those are skipped here, same +# as the existing root-bypasses-permissions guard, and counted so the final +# summary says how many were skipped rather than the count silently reading +# lower with no explanation (the same transparency +# adrrr/persistent-handoff's own Windows leg documents for its 3 skips). +is_windows() { [ -n "${MSYSTEM:-}" ]; } +skip_win() { SKIPPED_WINDOWS=$((SKIPPED_WINDOWS + 1)); ok "$1 (skipped: NTFS chmod can't stage this)"; } WORK=$(mktemp -d 2>/dev/null || echo "/tmp/tl-tests.$$") mkdir -p "$WORK/proj/.claude/throughline/buffer" @@ -571,8 +583,12 @@ has "a buffer with zero conforming lines is still counted, not dropped" "$O3a4" # rather than feeding an empty operand to the integer test, which would # otherwise leak a shell diagnostic to stderr, breaking this hook's # always-silent-on-error contract (every other error path here is -# 2>/dev/null'd). Skipped when running as root, which bypasses permissions. -if [ "$(id -u)" != "0" ]; then +# 2>/dev/null'd). Skipped when running as root, which bypasses permissions, +# or on Windows/NTFS, which can't stage an unreadable-to-owner file (see +# is_windows() above). +if is_windows; then + skip_win "unreadable-buffer stderr test" +elif [ "$(id -u)" != "0" ]; then reset_buf printf -- '- x\n' > "$BUF/session-T.md" printf 'test' > "$BUF/session-UNREAD.md" @@ -606,9 +622,18 @@ has "no-end-stamp buffer surfaced with hedged wording" "$O3b" 'no end-stamp' hasnt "no-end-stamp buffer NOT mislabeled as ended" "$O3b" 'ended without' # 11. missing jq surfaces a visible warning in onboard (curated PATH without jq) +# A tiny exec wrapper script per tool, not `ln -sf` or `cp`: a symlink needs +# an elevated privilege Windows doesn't grant by default (issue #67), and a +# COPY of the real binary is worse - on macOS a Homebrew-built binary can +# resolve its shared libraries via an @executable_path-relative reference, +# which breaks (hangs or crashes) once the binary is relocated to $STUB. A +# plain `#!/bin/sh -c 'exec "$@"'` wrapper needs no privilege beyond +# writing a text file and never touches the real binary's own location. STUB="$WORK/bin"; mkdir -p "$STUB" for c in sh dirname cat grep git tr head; do - real=$(command -v "$c" 2>/dev/null) && ln -sf "$real" "$STUB/$c" + real=$(command -v "$c" 2>/dev/null) || continue + printf '#!/bin/sh\nexec "%s" "$@"\n' "$real" > "$STUB/$c" + chmod +x "$STUB/$c" done O4=$(printf '%s' '{"source":"startup","session_id":"T"}' | PATH="$STUB" sh "$H/session-onboard.sh") has "onboard warns when jq is missing" "$O4" 'jq' @@ -678,7 +703,11 @@ present "capture-first auto-activates and writes a buffer entry" "$FRESH_D/.clau FRESH_E="$WORK/fresh-e" mkdir -p "$FRESH_E" fixture_repo "$FRESH_E" -if [ "$(id -u)" != "0" ]; then +if is_windows; then + skip_win "failed-bootstrap warning test" + skip_win "failed-bootstrap no-dir-created test" + skip_win "failed-bootstrap path-relativization test" +elif [ "$(id -u)" != "0" ]; then chmod 555 "$FRESH_E" 2>/dev/null O8=$(printf '%s' '{"source":"startup","session_id":"T"}' | CLAUDE_PROJECT_DIR="$FRESH_E" sh "$H/session-onboard.sh") chmod 755 "$FRESH_E" 2>/dev/null @@ -1066,5 +1095,6 @@ eq "precompact: boundary after a post-boundary action stamps again" "$(grep -c ' echo "----------------------" printf 'passed: %s failed: %s\n' "$PASS" "$FAIL" +[ "$SKIPPED_WINDOWS" -eq 0 ] || printf ' (%s permission-based assertion(s) skipped on Windows/NTFS - see is_windows() above)\n' "$SKIPPED_WINDOWS" rm -rf "$WORK" [ "$FAIL" -eq 0 ] From c8ef145e610b81a917069b5f53d2de3440965293 Mon Sep 17 00:00:00 2001 From: Jason Irish Date: Tue, 1 Sep 2026 11:40:12 -0500 Subject: [PATCH 2/3] fix(#67): fix the real Windows CI failures from PR #75's first push The Windows leg of this PR's own CI failed on first push (172 passed, 3 failed) - exactly the outcome the PR was honest about not being able to verify locally. /review-pr independently found the same two root causes by reading the live CI output, plus two hardening gaps in the fix itself: 1. session-capture.sh passed the project root to jq as `--arg root "$root"`, then string-compared it against a captured file_path delivered via stdin. On Windows, jq is a native (non-MSYS) binary, and MSYS auto-converts a POSIX-looking ARGUMENT to Windows form before a native executable sees it - so $root arrived pre-converted while file_path didn't, and the two never matched. Every Edit/Write/NotebookEdit path onboard wrote stayed unrelativized on Windows: an absolute, OS-shaped path leaking into the buffer. First attempted fix (MSYS_NO_PATHCONV=1, global) was wrong and never shipped past local testing: the review caught that it would have broken session-onboard.sh's own `jq ... plugin.json` read, which legitimately NEEDS argv conversion to open that file on Windows. The actual fix keeps $root out of argv entirely - passed as an environment variable (`root="$root" jq ...`, referenced as `env.root`), which sidesteps MSYS's argv-only conversion without an opt-out that breaks something else. 2. A new test (7b3, from the earlier commit today) built a ~300-character path to force a git-status line past the truncation threshold. Windows' MAX_PATH (260) doesn't apply to a bare mkdir under Git Bash, but DOES apply to git's own add/commit machinery, so the fixture never staged. Shortened the path to a size that still exceeds the 200-char truncation threshold with margin while staying under 260, and added `git config core.longpaths true` as a second line of defense against whatever the real runner's own temp-path depth turns out to be. Also from the same review pass: - is_windows() checked only $MSYSTEM, which is set by the Git-for-Windows wrapper bash.exe, not the MSYS runtime itself - anything invoking usr/bin/sh.exe or usr/bin/bash.exe directly would leave it unset and the permission-test skips would silently stop firing. Added `uname -s` (MINGW*/MSYS*/CYGWIN*) and $OS=Windows_NT as backstops that come from the kernel/process environment directly. - Write and NotebookEdit path relativization only asserted the relativized form was PRESENT, not that the absolute prefix was ABSENT - the same gap that let the Edit-path bug above ship unnoticed for those two tool types too (they share the identical code path and failed identically on Windows). Added the missing negative assertions. 174 -> 176 passing tests. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01UXL2ffVDsAp4P7387Nr18G --- hooks/session-capture.sh | 16 +++++++++-- hooks/session-onboard.sh | 4 +-- tests/run.sh | 58 +++++++++++++++++++++++++++++----------- 3 files changed, 59 insertions(+), 19 deletions(-) diff --git a/hooks/session-capture.sh b/hooks/session-capture.sh index 312798c..62d33e4 100755 --- a/hooks/session-capture.sh +++ b/hooks/session-capture.sh @@ -68,7 +68,19 @@ mkdir -p "$bufdir" 2>/dev/null || { tl_err "mkdir failed for buffer dir"; exit 0 # the identical final sanitized id even in the currently-unreachable case of a # stranger one. A regression test locks in that capture and flush agree on the # filename for a tab-containing id. -out=$(printf '%s' "$input" | jq -r --arg root "$root" "$(tl_jq_redact_defs)"' +# +# $root reaches jq through the ENVIRONMENT (env.root below), not `--arg root +# "$root"`: on Windows Git Bash, jq is a native, non-MSYS binary, and MSYS +# auto-converts a POSIX-looking ARGUMENT to a Windows-native path before a +# native executable ever sees it (issue #67, root-caused via a real Windows +# CI run - `$root` arrived at jq already rewritten while the file_path being +# compared against it, delivered over stdin, stayed untouched, so the two +# never matched and every path this hook wrote stayed unrelativized). MSYS's +# conversion targets argv, not arbitrary environment variables, so passing it +# this way sidesteps the mismatch entirely - and does so without a blanket +# opt-out that would also break session-onboard.sh's own `jq ... plugin.json` +# read, which legitimately NEEDS argv conversion to open that file on Windows. +out=$(printf '%s' "$input" | root="$root" jq -r "$(tl_jq_redact_defs)"' # Observable outcome from the tool result. The Claude Code Bash tool_response # exposes "interrupted" but NOT an exit code, so a plain non-zero exit is not # visible to a PostToolUse hook and is deliberately left unmarked rather than @@ -94,7 +106,7 @@ out=$(printf '%s' "$input" | jq -r --arg root "$root" "$(tl_jq_redact_defs)"' ((.tool_input.command // "") | redact | clean | clamp(200; "…[truncated]")) + "`" elif ($t == "Edit" or $t == "Write" or $t == "NotebookEdit") then "**" + $t + "** " + - ((.tool_input.file_path // .tool_input.notebook_path // "?") | ltrimstr($root + "/") | redact | clean) + + ((.tool_input.file_path // .tool_input.notebook_path // "?") | ltrimstr(env.root + "/") | redact | clean) + outcome($t) # High-signal read-side tools (issue #6): one redacted+cleaned argument # each, same outcome suffix. Each argument is clamped to a short prefix so diff --git a/hooks/session-onboard.sh b/hooks/session-onboard.sh index 738162f..a599b72 100755 --- a/hooks/session-onboard.sh +++ b/hooks/session-onboard.sh @@ -261,7 +261,7 @@ if [ "$in_worktree" = "1" ]; then git -C "$root" status -s 2>/dev/null | head -20 | awk -v max="$TL_GIT_STATUS_LINE_CHARS" ' { if (length($0) > max) print substr($0, 1, max) " …[line truncated]" else print - }' + }' 2>/dev/null echo '```' fi @@ -388,7 +388,7 @@ if [ "$src" = "compact" ] && [ -n "$sid" ] && [ -f "$bufdir/session-$sid.md" ]; tail -n "$TL_COMPACT_TAIL_LINES" "$buf" 2>/dev/null | awk -v max="$TL_COMPACT_TAIL_LINE_CHARS" ' { if (length($0) > max) print substr($0, 1, max) " …[line truncated]" else print - }' + }' 2>/dev/null echo '```' fi diff --git a/tests/run.sh b/tests/run.sh index 984c985..35f2ef6 100644 --- a/tests/run.sh +++ b/tests/run.sh @@ -44,15 +44,26 @@ PASS=0 FAIL=0 SKIPPED_WINDOWS=0 -# Git Bash / MSYS2 sets MSYSTEM (MINGW64, UCRT64, MSYS, ...); nothing else -# realistically sets it (issue #67). A few permission-based assertions below -# stage a POSIX mode bit (000/444/555) that NTFS's chmod emulation cannot -# reliably enforce the same way ext4/APFS do - those are skipped here, same -# as the existing root-bypasses-permissions guard, and counted so the final -# summary says how many were skipped rather than the count silently reading -# lower with no explanation (the same transparency -# adrrr/persistent-handoff's own Windows leg documents for its 3 skips). -is_windows() { [ -n "${MSYSTEM:-}" ]; } +# A few permission-based assertions below stage a POSIX mode bit (000/444/555) +# that NTFS's chmod emulation cannot reliably enforce the same way ext4/APFS +# do - those are skipped on Windows, same as the existing +# root-bypasses-permissions guard, and counted so the final summary says how +# many were skipped rather than the count silently reading lower with no +# explanation (the same transparency adrrr/persistent-handoff's own Windows +# leg documents for its skips). +# +# review finding (issue #67): checking only MSYSTEM fails OPEN, not closed - +# it's set by the Git-for-Windows wrapper bash.exe hands off to, not by the +# MSYS runtime itself, so anything invoking usr/bin/sh.exe or usr/bin/bash.exe +# directly (a plausible shape for how a harness spawns a command hook, and +# exactly the scenario this issue exists to cover) leaves it unset and the +# guards below would silently stop firing. `uname -s` (MINGW*/MSYS*/CYGWIN*) +# and $OS=Windows_NT come from the kernel/process environment directly, not a +# wrapper script, so either backstops the case MSYSTEM alone misses. +is_windows() { + case "$(uname -s 2>/dev/null)" in MINGW*|MSYS*|CYGWIN*) return 0 ;; esac + [ -n "${MSYSTEM:-}" ] || [ "${OS:-}" = "Windows_NT" ] +} skip_win() { SKIPPED_WINDOWS=$((SKIPPED_WINDOWS + 1)); ok "$1 (skipped: NTFS chmod can't stage this)"; } WORK=$(mktemp -d 2>/dev/null || echo "/tmp/tl-tests.$$") @@ -294,9 +305,19 @@ E=$(grep Edit "$BUF/session-T.md") has "Edit path is relativized to project root" "$E" 'src/app.js' hasnt "Edit path drops the absolute prefix" "$E" "$WORK" cap '{"session_id":"T","tool_name":"Write","tool_input":{"file_path":"'"$WORK"'/proj/src/new.js"}}' -has "Write path is relativized to project root" "$(grep '\*\*Write\*\*' "$BUF/session-T.md")" 'src/new.js' +W=$(grep '\*\*Write\*\*' "$BUF/session-T.md") +has "Write path is relativized to project root" "$W" 'src/new.js' +# review finding: only the Edit assertion above checked for the ABSENCE of +# the absolute prefix - Write and NotebookEdit share the identical +# relativization code path and would fail identically (as they briefly did +# on Windows, per env.root vs. --arg root above) without anyone catching it, +# since "the tail is present" alone stays true even when the prefix was +# never stripped. +hasnt "Write path drops the absolute prefix" "$W" "$WORK" cap '{"session_id":"T","tool_name":"NotebookEdit","tool_input":{"notebook_path":"'"$WORK"'/proj/nb/a.ipynb"}}' -has "NotebookEdit uses notebook_path fallback" "$(grep NotebookEdit "$BUF/session-T.md")" 'nb/a.ipynb' +NB=$(grep NotebookEdit "$BUF/session-T.md") +has "NotebookEdit uses notebook_path fallback" "$NB" 'nb/a.ipynb' +hasnt "NotebookEdit path drops the absolute prefix" "$NB" "$WORK" cap '{"session_id":"T","tool_name":"Write","tool_input":{}}' has "Write with neither path key falls back to ?" "$(grep '\*\*Write\*\* ?' "$BUF/session-T.md")" '**Write** ?' @@ -498,12 +519,19 @@ before "onboard(compact) live git state precedes the buffer-tail inline" \ # path is truncated per-line the same way an oversized buffer-tail line # already is (7c below), keeping this block bounded on both axes the # way its "renders first, has no fallback" placement (7b2) assumes. +# Path length is tuned deliberately, not just "as long as possible": the +# repo-relative path needs to push a git-status line past +# TL_GIT_STATUS_LINE_CHARS=200, but the FULL filesystem path (this fixture's +# $WORK prefix included) must stay under Windows' classic 260-character +# MAX_PATH - review finding: the original version of this fixture exceeded +# it and failed to even stage on Windows CI (git init/add/commit itself +# failed), never reaching the assertion this test exists to make. FRESH_LONGPATH="$WORK/fresh-longpath" -LONGPATH="a/very/deeply/nested/directory/structure/that/goes/on/for/quite/a/while/to/simulate/a/real/monorepo/with/excessively/long/generated/paths" -LONGNAME="a_very_long_generated_filename_that_pushes_this_line_well_past_two_hundred_characters_in_length.txt" +LONGPATH="a/very/deeply/nested/directory/structure/for/testing" +LONGNAME="a_generated_file_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.txt" mkdir -p "$FRESH_LONGPATH/$LONGPATH" : > "$FRESH_LONGPATH/$LONGPATH/$LONGNAME" -( cd "$FRESH_LONGPATH" && git init -q && git add -A && git commit -q -m init ) 2>/dev/null \ +( cd "$FRESH_LONGPATH" && git init -q && git config core.longpaths true && git add -A && git commit -q -m init ) 2>/dev/null \ || bad "fixture setup failed: $FRESH_LONGPATH (git init/add/commit)" printf -- 'changed\n' >> "$FRESH_LONGPATH/$LONGPATH/$LONGNAME" O_LONGPATH=$(printf '%s' '{"source":"startup","session_id":"T"}' | CLAUDE_PROJECT_DIR="$FRESH_LONGPATH" sh "$H/session-onboard.sh") @@ -630,7 +658,7 @@ hasnt "no-end-stamp buffer NOT mislabeled as ended" "$O3b" 'ended without' # plain `#!/bin/sh -c 'exec "$@"'` wrapper needs no privilege beyond # writing a text file and never touches the real binary's own location. STUB="$WORK/bin"; mkdir -p "$STUB" -for c in sh dirname cat grep git tr head; do +for c in sh dirname cat grep git tr head awk; do real=$(command -v "$c" 2>/dev/null) || continue printf '#!/bin/sh\nexec "%s" "$@"\n' "$real" > "$STUB/$c" chmod +x "$STUB/$c" From 062c881b37a4d339d8ca74ea21994447dad9734d Mon Sep 17 00:00:00 2001 From: Jason Irish Date: Tue, 1 Sep 2026 11:46:17 -0500 Subject: [PATCH 3/3] fix(#67): the env-var fix for path relativization ALSO failed on Windows The previous commit's fix (root="$root" jq ..., referenced as env.root) looked right by MSYS documentation but didn't survive contact with the real runner: the second CI push failed identically - all three of Edit, Write, and NotebookEdit still had their absolute prefix intact. Whatever channel is actually doing the conversion on this runner, both an argv value AND an environment variable turned out to go through it. Stopped trying to find a channel MSYS won't touch and removed the channel entirely: $root is now embedded as a JSON string LITERAL inside the jq PROGRAM TEXT itself, computed via a separate `jq -Rs .` call fed over stdin (the one channel already confirmed immune, since the file_path it's compared against uses the same one). The whole filter argument - starting with `def outcome($t): ...` and hundreds of characters long - doesn't remotely resemble "a path" as a single argument, which is what MSYS's conversion heuristic actually keys on; the value never appears as its own argv item or env var for anything to convert. Verified locally against a path containing a space, then again against one with an embedded double quote and backslash (jq -Rs . correctly escapes both), so this isn't fragile the way string-concatenation-into-a-shell- command usually is. This is now the third fix attempt for the same underlying symptom, each verified locally and each pushed to find out against the one environment that actually matters. Real Windows CI is watching this push. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01UXL2ffVDsAp4P7387Nr18G --- hooks/session-capture.sh | 34 +++++++++++++++++++++------------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/hooks/session-capture.sh b/hooks/session-capture.sh index 62d33e4..a0893b3 100755 --- a/hooks/session-capture.sh +++ b/hooks/session-capture.sh @@ -69,18 +69,26 @@ mkdir -p "$bufdir" 2>/dev/null || { tl_err "mkdir failed for buffer dir"; exit 0 # stranger one. A regression test locks in that capture and flush agree on the # filename for a tab-containing id. # -# $root reaches jq through the ENVIRONMENT (env.root below), not `--arg root -# "$root"`: on Windows Git Bash, jq is a native, non-MSYS binary, and MSYS -# auto-converts a POSIX-looking ARGUMENT to a Windows-native path before a -# native executable ever sees it (issue #67, root-caused via a real Windows -# CI run - `$root` arrived at jq already rewritten while the file_path being -# compared against it, delivered over stdin, stayed untouched, so the two -# never matched and every path this hook wrote stayed unrelativized). MSYS's -# conversion targets argv, not arbitrary environment variables, so passing it -# this way sidesteps the mismatch entirely - and does so without a blanket -# opt-out that would also break session-onboard.sh's own `jq ... plugin.json` -# read, which legitimately NEEDS argv conversion to open that file on Windows. -out=$(printf '%s' "$input" | root="$root" jq -r "$(tl_jq_redact_defs)"' +# $root reaches jq embedded as a JSON string LITERAL inside the jq PROGRAM +# TEXT ($root_json below), not as `--arg root "$root"` and not as an +# environment variable either - both of those were tried and both failed +# identically on a real Windows CI run (issue #67). On Windows Git Bash, jq +# is a native, non-MSYS binary, and MSYS auto-converts a POSIX-looking value +# to Windows-native form before a native executable sees it; `--arg root` +# passes $root as its own, whole argv item - exactly what MSYS's "does this +# whole argument look like a path" heuristic matches - and an environment +# variable turned out to be converted the same way. The file_path being +# compared against it arrives over stdin, which MSYS never touches, so the +# two never matched and every path this hook wrote stayed unrelativized. +# Embedding the value as a JSON string INSIDE the much larger jq filter +# text - which starts with `def outcome($t): ...` and is nowhere close to +# looking like a path as a whole argument - sidesteps the heuristic +# entirely, whichever exact channel (argv vs. env) was actually converting +# it. $root_json is built via a SEPARATE jq call fed over stdin (line below), +# the one channel already confirmed immune to this, so the value it embeds +# is the untouched original. +root_json=$(printf '%s' "$root" | jq -Rs .) +out=$(printf '%s' "$input" | jq -r "$(tl_jq_redact_defs)""(${root_json}) as \$proot | "' # Observable outcome from the tool result. The Claude Code Bash tool_response # exposes "interrupted" but NOT an exit code, so a plain non-zero exit is not # visible to a PostToolUse hook and is deliberately left unmarked rather than @@ -106,7 +114,7 @@ out=$(printf '%s' "$input" | root="$root" jq -r "$(tl_jq_redact_defs)"' ((.tool_input.command // "") | redact | clean | clamp(200; "…[truncated]")) + "`" elif ($t == "Edit" or $t == "Write" or $t == "NotebookEdit") then "**" + $t + "** " + - ((.tool_input.file_path // .tool_input.notebook_path // "?") | ltrimstr(env.root + "/") | redact | clean) + + ((.tool_input.file_path // .tool_input.notebook_path // "?") | ltrimstr($proot + "/") | redact | clean) + outcome($t) # High-signal read-side tools (issue #6): one redacted+cleaned argument # each, same outcome suffix. Each argument is clamped to a short prefix so