* fix(#166): omit bash.exe wrapper for windows claude sh hooks
* chore(#166): add changeset for windows claude sh hook fix
* fix(#166): reset exitCode in sdk bridge integration test
Three parallel diagnostic agents (G: sdk/install path-sep, H: hook scripts,
I: worktree/workspace) characterised the remaining windows-22 failures on
23b52f1a. Patches by class:
CRLF in test parsers / file-content reads
- bug-2136-sh-hook-version: shebang check split('\n') → split(/\r?\n/)
- bug-3542-executor-git-stash-prohibition: strip \r from read content
(git rewrites stashed text with CRLF on win autocrlf=true checkout)
- workspace: BOM-strip + explicit \r strip in parseCommandFile (BOM at
byte 0 defeats /^---/ anchor → fmMatch null → fm.name undefined)
Windows path-separator / 8.3-shortname normalization
- bug-3491-nested-git-worktree: use fs.realpathSync.native to expand
%TEMP% RUNNER~1 → runneradmin; normalize sep before path-equality
- prune-orphaned-worktrees: normalize \\→/ before substring includes
(git emits forward-slash in --porcelain on Windows even when
path.join produced backslashes)
- bug-3017-codex-hook-absolute-node: accept POSIX path OR path with
drive-letter prefix in hookPath equality assertion
- bug-3126-global-skills-base-runtime-path: use path.join for expected
/xdg/<runtime> values (production calls path.join → \xdg\… on win32)
Test under-specified platform / forgot win32 env
- bug-2979-hook-absolute-node: pass {platform:'linux'} to
buildHookCommand + rewriteLegacyManagedNodeHookCommands so the
POSIX-branch tests don't pick up the #3393 GitBash code path
- bug-3288-model-catalog + bug-3571-config-manifest: also set
USERPROFILE alongside HOME so os.homedir() on win32 redirects to
the test fixture instead of the runner's real ~
External-cmd resolution
- bug-2647-outer-tarball-sdk-dist: use npm.cmd + {shell:true} on win32
so execFileSync resolves PATHEXT (literal `npm` is ENOENT)
ESM loader: tests/runtime-bridge-sync-smoke.test.cjs already migrated to
pathToFileURL in 23b52f1a (cluster E).
Explicit per-test/describe skip on win32 (with required string reasons
to satisfy no-unconditional-win32-skip guard)
- feat-3347-graphify-auto-update-hook: 3 describes — harness spawns
bash/kill/sleep + the hook itself is bash
- feat-3595-fs-fault-injection: move \t and \n filenames into the
POSIX-only branch (NTFS forbids 0x00–0x1F in filenames)
- bug-2775/2829/3033/3231/3359: POSIX shim under ~/.local/bin with
chmod 0o755 — not how Windows install works
- bug-3211: single subtest where cp.execSync reassignment isn't
picked up on win32 (POSIX coverage via the mock; live windows
behavior covered by 3211-D which keeps running)
- install-path-detection: parses sh-style export PATH= rc files;
Windows has no rc files (registry Path)
- worktree-safety-policy: single test using POSIX /repo/wt fixture
paths that can't be expressed under win32 path.resolve
Validated: plex2 (ubuntu docker) 11224/0 pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test: cover Windows hook shell drift for Claude (#3413)
* fix: scope Windows hook syntax to Gemini runtime (#3413)
* docs: add changeset for #3413
* docs: set changeset pr for #3413
* fix: route Windows hook formatting through runtime-aware projection seam
* test: cover runtime projection edge cases for Windows hooks
* fix(docs): add shell-command-projection to inventory parity
* docs: align CLI module shipped count after rebase
The three opt-in bash hooks (gsd-phase-boundary.sh, gsd-session-state.sh,
gsd-validate-commit.sh) shipped with #!/bin/bash, which fails on distros
that don't ship bash at /bin/bash (NixOS, minimal Alpine images, some
container runtimes). POSIX guarantees /bin/sh but not /bin/bash.
This is latent in the default install path because Claude Code wires the
hooks as `bash <path>` from settings.json (PATH-resolved — the script's
own shebang is read as a comment by bash). The fix matters when scripts
are run directly: tests, future installer changes, or manual debugging.
Changes:
- hooks/gsd-{phase-boundary,session-state,validate-commit}.sh: shebang
switched to #!/usr/bin/env bash, matching the convention already used
in scripts/*.sh.
- tests/bug-2136-sh-hook-version.test.cjs: assertion updated to expect
the new shebang; comment updated to spell out the rationale.
- tests/bug-2979-hook-absolute-node.test.cjs: doc-comment updated — the
prior wording cited "POSIX std PATH always has /bin" as the reason
bare `bash` is OK. The actual reason is that bare `bash` is
PATH-resolved, which is portable across distros that don't ship
/bin/bash. POSIX std PATH guarantees /bin/sh, not /bin/bash.
- bin/install.js::buildHookCommand: comment block clarifying the same.
No behavior change in this file — bare `bash` was already correct.
- .changeset/portable-bash-shebang-hooks.md: changeset entry.
Verified locally on NixOS:
- npm run build:hooks: hooks/dist/*.sh shebangs propagate correctly.
- node --test tests/bug-2136-*.cjs tests/bug-2979-*.cjs
tests/bug-1817-*.cjs tests/bug-1834-*.cjs tests/bug-1906-*.cjs
tests/bug-2557-*.cjs tests/bug-3017-*.cjs tests/security-scan.test.cjs
tests/hooks-doc-parity.test.cjs: 126/126 pass.
- node scripts/run-tests.cjs (full suite): 6944 pass / 0 fail / 5 skip.
* fix(#2979): emit absolute node path in managed hooks for GUI/minimal-PATH runtimes
Installer-emitted hook commands started with bare 'node' which works
under interactive shells (nvm/Homebrew/Volta on PATH) but fails in
GUI-launched runtimes that start with /usr/bin:/bin:/usr/sbin:/sbin.
Every managed JS hook (gsd-check-update, gsd-statusline, gsd-context-monitor,
gsd-prompt-guard, gsd-read-guard, gsd-read-injection-scanner,
gsd-workflow-guard) failed with /bin/sh: node: command not found —
silently disabling update checks, statusline, and security guards.
Fix: new resolveNodeRunner() helper returns process.execPath (the
absolute path of the Node binary running the installer) forward-slash-
normalized and double-quoted. Used in:
- buildHookCommand() for global installs (.js runner)
- local-install code paths for all 7 managed JS hooks
.sh hooks keep bare 'bash' — /bin/bash is in the POSIX standard PATH
and always resolves under minimal-PATH GUI launches.
Tests: bug-2979-hook-absolute-node.test.cjs parses emitted commands
into { runner, hookPath } records and asserts:
- resolveNodeRunner returns quoted absolute forward-slash node path
- .js hooks emit absolute runner (default and portableHooks modes)
- .sh hooks still emit bare 'bash'
Closes#2979
* chore(#2979): add changeset fragment for PR #3002
* chore(#2979): add changeset fragment for PR #3002
* fix(#2979): resolveNodeRunner returns null on missing execPath; rewrite legacy bare-node managed hooks (CR feedback)
CodeRabbit on PR #3002 caught two issues:
1. resolveNodeRunner fell back to bare 'node' when process.execPath was
empty -- recreating the exact #2979 bug. Now returns null. Callers
(buildHookCommand and the local-install code paths) check for null
and skip registration rather than emit a broken command.
2. The original #2979 fix only updated NEWLY registered hooks. Existing
bare-node managed hook entries from pre-#2979 installs stayed
broken across reinstalls. New rewriteLegacyManagedNodeHookCommands
walks settings.hooks and rewrites any managed-hook entry that starts
with bare 'node ' to use the absolute runner. Filename allowlist
(gsd-check-update.js, gsd-statusline.js, gsd-context-monitor.js,
gsd-prompt-guard.js, gsd-read-guard.js, gsd-read-injection-scanner.js,
gsd-workflow-guard.js) ensures user-authored bare-node hooks are
left untouched.
Tests: bug-2979-hook-absolute-node.test.cjs grows by 8 cases:
- 5 for the migration walker (rewrites managed entries, leaves quoted-
runner entries alone, leaves user-authored entries alone, leaves .sh
entries alone, no-ops on null runner).
- 2 for resolveNodeRunner returning null on empty execPath.
- 1 for buildHookCommand returning null when execPath unavailable.
* chore(#3002): drop direct CHANGELOG.md edit; release entry now lives in .changeset/
The changeset-fragment workflow (#2975) renders fragments into
CHANGELOG.md at release time. Direct edits to [Unreleased] on
each PR caused merge conflicts on every concurrent PR. This commit
restores CHANGELOG.md to match origin/main; the release entry for
this fix is preserved in the .changeset/*.md fragment(s) on this
branch, which the release workflow consolidates.
* fix(#2979): guard hook + statusline pushes against null commands (CR follow-up)
CodeRabbit on PR #3002 found an outside-diff issue: when
resolveNodeRunner() returns null, every dependent *Command becomes
null, but the registration sites still pushed { type: 'command',
command: null } entries onto settings.hooks. The runtime's hook
schema rejects null commands and the failure surfaces as a confusing
parse error.
Fix:
- One unified warning at the top of configureSettings when ANY JS-hook
command resolves null (operator sees the cause once instead of per-hook).
- Each of the 6 managed JS hook registration if-clauses now guards on
the *Command variable being truthy: && updateCheckCommand,
&& contextMonitorCommand, && promptGuardCommand, && readGuardCommand,
&& readInjectionScannerCommand, && workflowGuardCommand.
- Statusline registration adds an else-if (!statuslineCommand) clause
with its own warn before the settings.statusLine write site.
Tests: bug-2979-hook-absolute-node.test.cjs grows by 7 cases
(6 per-hook structural assertions parsing install.js for the
`fs.existsSync(<file>) && <command>` shape, plus 1 statusline
guard-precedes-write test).
* fix(#2979): defense-in-depth validateHookFields before writeSettings (CR)
CodeRabbit on PR #3002 (post-fix-up review): replace source-grep
structural tests with behavioral assertions on the settings object.
The push-site `&& <command>` guards (commit ce696c64) prevent null
commands from being pushed in the first place. As a defense-in-depth
backstop, install.js now runs validateHookFields(settings) right
before writeSettings(); validateHookFields already filters
{type:'command', command: null} entries (line 5884), so even if my
push-site guards ever regress, no null-command entries reach disk.
Tests: replaced the 7 install.js source-grep tests with 8 truly
behavioral tests:
- validateHookFields strips null-command entries for each of the 6
managed JS hook shapes (parameterized by event + matcher)
- validateHookFields drops the entry entirely when all its hooks are
null-command
- validateHookFields preserves agent-type hooks while stripping
null-command sibling hooks in the same entry
These tests exercise the actual function the production code uses,
not its source representation. They survive future refactors of the
registration call sites.
* fix(#2979): tighten managed-hook migration to basename equality (CR)
CodeRabbit on PR #3002 (post-fix-up review): the previous
`trimmed.includes(name)` matcher had a false-positive vector. A
user-authored hook whose path contained a managed filename as a
substring (e.g. /home/me/scripts/wraps-gsd-check-update.js-helper.js)
would be unconditionally rewritten with the GSD runner, replacing
the user's bare `node` with our absolute path -- silently mutating
their hook configuration.
Fix: parse the command into <runner> <script-token> with the
script-token allowed to be quoted (single or double) or bareword.
Extract the path inside quotes, take the basename (handles both
forward and backslash separators on Windows), and match against
MANAGED_HOOK_FILES via Set.has() — exact equality, not substring.
Tests: bug-2979 grows by 4 cases:
- user hook with managed-filename-as-substring is NOT rewritten
- single-quoted path: rewritten correctly
- bareword path: rewritten correctly
- Windows backslash path: basename extraction works