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/hooks/session-capture.sh b/hooks/session-capture.sh index 312798c..a0893b3 100755 --- a/hooks/session-capture.sh +++ b/hooks/session-capture.sh @@ -68,7 +68,27 @@ 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 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 @@ -94,7 +114,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($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 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 3a6936e..35f2ef6 100644 --- a/tests/run.sh +++ b/tests/run.sh @@ -42,6 +42,29 @@ ROOT=$(unset CDPATH; cd -- "$(dirname -- "$0")/.." && pwd) H="$ROOT/hooks" PASS=0 FAIL=0 +SKIPPED_WINDOWS=0 + +# 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.$$") mkdir -p "$WORK/proj/.claude/throughline/buffer" @@ -282,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** ?' @@ -486,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") @@ -571,8 +611,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 +650,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" +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" 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 +731,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 +1123,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 ]