From 1c9da7620cd0521cd2fdc7f585dd70b10758c635 Mon Sep 17 00:00:00 2001 From: fongwc <41223379+fongwc@users.noreply.github.com> Date: Wed, 29 Jul 2026 12:39:54 +0800 Subject: [PATCH] fix: resolveSlug returns "unknown" on Windows, sending decisions to projects/unknown lib/bin-context.ts spawns the `gstack-slug` bash script directly. Windows has no shebang support, so spawnSync fails with ENOENT, stdout is null, and the slug silently falls back to "unknown". Every bun-based bin that resolves a slug this way then reads and writes ~/.gstack/projects/unknown/ instead of the real project. In practice that means gstack-decision-log writes decisions somewhere gstack-decision-search on another project can also see, and the Context Recovery block in every skill preamble never surfaces them, because it checks for decisions.active.json under the correct slug. The failure is silent: exit 0, empty results, nothing in any log. Same root cause as the spawn half of #2356, and the same class the #1731 fix addressed for gbrain. That fix used `shell: true`, which is right for gbrain because gbrain ships a .cmd shim on Windows. gstack's own bins are extensionless bash scripts, so cmd.exe answers "is not recognized as an internal or external command" and only `spawnSync("bash", [script])` starts them: spawnSync(bin) -> ENOENT spawnSync(bin, {shell: true}) -> not recognized as an internal or external command spawnSync("bash", [bin]) -> status 0 Adds spawnBashBin() and routes resolveSlug through it. The direct spawn is still attempted first, so POSIX behaviour is byte-for-byte unchanged and the bash fallback only runs on the path that is currently broken. Verified on Windows 11, bun 1.3.13, Git Bash, global git install at ~/.claude/skills/gstack: before: gstack-decision-search --json -> [] (2 decisions on disk) after: gstack-decision-search --json -> 2 decisions, from both the repo root and a subdirectory with a different git remote Test suite unchanged: test/gbrain-spawn-windows-shell.test.ts, gstack-decision.test.ts, gstack-decision-bins.test.ts, bin-windows-bun-import-paths.test.ts and gstack-slug-sanitize.test.ts report 39 pass / 18 fail both before and after this commit on Windows at a3259400, so this introduces no regressions. Those 18 pre-existing failures look like separate Windows path bugs in the tests themselves and are not touched here. Co-Authored-By: Claude Opus 5 --- lib/bin-context.ts | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/lib/bin-context.ts b/lib/bin-context.ts index faa1c65a2..d7edaa5a9 100644 --- a/lib/bin-context.ts +++ b/lib/bin-context.ts @@ -6,10 +6,37 @@ */ import { spawnSync } from "child_process"; +import type { SpawnSyncOptionsWithStringEncoding, SpawnSyncReturns } from "child_process"; + +/** + * Spawn one of gstack's own `#!/usr/bin/env bash` bins. + * + * Windows has no shebang support, so `spawnSync(bin, ...)` on an extensionless + * bash script fails with ENOENT. `shell: true` does not help either: that routes + * through cmd.exe, which reports "is not recognized as an internal or external + * command". Only spawning `bash` with the script as argv[0] actually starts it. + * + * (`shell: true` IS correct for `gbrain` — see NEEDS_SHELL_ON_WINDOWS in + * lib/gbrain-exec.ts — but only because gbrain ships a `.cmd` shim on Windows, + * which cmd.exe can run. gstack's own bins have no such shim.) + * + * Tries the direct spawn first so POSIX keeps its existing behaviour untouched, + * and only falls back to bash when the direct spawn produced nothing. + */ +export function spawnBashBin( + binPath: string, + args: string[] = [], + opts: Partial = {}, +): SpawnSyncReturns { + const options = { encoding: "utf-8" as const, ...opts }; + const direct = spawnSync(binPath, args, options); + if (!direct.error && direct.stdout != null) return direct; + return spawnSync("bash", [binPath, ...args], options); +} /** Resolve the project slug via the `gstack-slug` helper (parses `SLUG=...`). */ export function resolveSlug(slugBinPath: string): string { - const r = spawnSync(slugBinPath, { encoding: "utf-8" }); + const r = spawnBashBin(slugBinPath); const m = (r.stdout || "").match(/^SLUG=(.+)$/m); return m ? m[1].trim() : "unknown"; }