Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -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
35 changes: 29 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 22 additions & 2 deletions hooks/session-capture.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions hooks/session-onboard.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
78 changes: 68 additions & 10 deletions tests/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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** ?'

Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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 <real> "$@"'` 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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 ]
Loading