From 8b679e2e0137340b74ceffc9bb1cf24a535c2d75 Mon Sep 17 00:00:00 2001 From: H M Ibtihal Utsho Date: Fri, 14 Aug 2026 00:10:17 -0400 Subject: [PATCH 1/2] fix(windows): resolve the project slug natively when gstack-slug cannot spawn bin/gstack-slug is a `#!/usr/bin/env bash` script with no file extension. Windows honors neither the shebang nor PATHEXT for an explicit path, so spawnSync fails ENOENT and resolveSlug returned its literal fallback, "unknown". Every decision on the machine was therefore filed under ~/.gstack/projects/unknown/ -- one bucket shared by every project -- while the bash-side Context Recovery preamble resolved the real slug, found no decisions.active.json there, and skipped through a bare `if [ -f ... ]` with no else. Nothing failed. Both decision bins (log and search) missed identically, so writes and searches stayed consistent with each other, and the only component that resolved correctly was silent by design. Measured on one machine: 62 decisions accumulated over 10 days and 170 skill runs, surfaced zero times. shell:true is not the fix here, unlike #1731 -- cmd.exe cannot run a bash script either. Nor is re-spawning through `bash`: on Windows that frequently resolves to WSL, whose $HOME and /mnt/c paths yield a different slug AND a different cache directory, trading one split store for another. Instead, port gstack-slug's own three steps (cache -> git remote -> basename), keeping its alphabet and its MSYS-form cache key so both paths agree. The fallback is win32-gated, so POSIX behaviour is byte-identical. Tests exercise the fallback on every platform (only the gating is win32-specific), so POSIX CI catches a regression that would otherwise surface only on a Windows user's disk, plus a static gate pinning the platform check. --- .github/workflows/windows-free-tests.yml | 2 + lib/bin-context.ts | 94 ++++++++++++++++++- test/bin-context-windows-slug.test.ts | 113 +++++++++++++++++++++++ 3 files changed, 207 insertions(+), 2 deletions(-) create mode 100644 test/bin-context-windows-slug.test.ts diff --git a/.github/workflows/windows-free-tests.yml b/.github/workflows/windows-free-tests.yml index c05a7d002..6f0df2daf 100644 --- a/.github/workflows/windows-free-tests.yml +++ b/.github/workflows/windows-free-tests.yml @@ -115,6 +115,8 @@ jobs: browse/test/server-sanitize-surrogates.test.ts \ test/setup-windows-fallback.test.ts \ test/bin-windows-bun-import-paths.test.ts \ + test/bin-context-windows-slug.test.ts \ + test/artifacts-allowlist-decisions.test.ts \ test/build-script-shell-compat.test.ts \ test/docs-config-keys.test.ts \ test/brain-sync-windows-paths.test.ts \ diff --git a/lib/bin-context.ts b/lib/bin-context.ts index faa1c65a2..28021b56a 100644 --- a/lib/bin-context.ts +++ b/lib/bin-context.ts @@ -6,12 +6,102 @@ */ import { spawnSync } from "child_process"; +import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs"; +import { homedir } from "os"; +import { basename, join } from "path"; -/** Resolve the project slug via the `gstack-slug` helper (parses `SLUG=...`). */ +/** Keep the slug inside the [a-zA-Z0-9._-] alphabet gstack-slug promises (`tr -cd`). */ +function sanitizeSlug(s: string): string { + return s.replace(/[^a-zA-Z0-9._-]/g, ""); +} + +/** + * A Windows path in the MSYS form git-bash's `pwd` reports: + * `C:\Users\j\foo` → `/c/Users/j/foo`. gstack-slug keys its cache on THAT form + * (`tr '/' '_'`), so a native lookup must reproduce it exactly or it misses the very + * entry gstack-slug wrote and silently re-derives instead of staying consistent. + * Exported for the cache-key test; non-Windows paths pass through unchanged. + */ +export function toMsysPath(p: string): string { + const drive = p.match(/^([A-Za-z]):[\\/]/); + const body = (drive ? p.slice(2) : p).replace(/\\/g, "/"); + return drive ? `/${drive[1].toLowerCase()}${body}` : body; +} + +/** + * Native port of bin/gstack-slug's resolution order, used when that script cannot be + * spawned (see resolveSlug). Same three steps, same alphabet, same cache file — so + * this and the shell path always agree. They must: the bins WRITE using this, while + * the Context Recovery preamble READS using the script. + */ +export function slugFromEnvironment(gstackHome?: string, cwd: string = process.cwd()): string { + const home = gstackHome || process.env.GSTACK_HOME || join(homedir(), ".gstack"); + const cacheDir = join(home, "slug-cache"); + const cacheFile = join(cacheDir, toMsysPath(cwd).replace(/\//g, "_")); + + let slug = ""; + // 1. cached slug wins (guarantees consistency across sessions) + if (existsSync(cacheFile)) { + try { + slug = sanitizeSlug(readFileSync(cacheFile, "utf-8").trim()); + } catch { + slug = ""; + } + } + // 2. else derive from the git remote: [:/]/[.git] → owner-repo + if (!slug) { + const r = spawnSync("git", ["remote", "get-url", "origin"], { encoding: "utf-8", cwd }); + const m = (r.stdout || "").trim().match(/[:/]([^/]+\/[^/]+?)(?:\.git)?$/); + if (m) slug = sanitizeSlug(m[1].replace(/\//g, "-")); + } + // 3. else the directory name + if (!slug) slug = sanitizeSlug(basename(cwd)); + if (!slug) return "unknown"; + + // 4. cache it, as gstack-slug does — atomic, and failures stay silent (`|| true`) + try { + mkdirSync(cacheDir, { recursive: true }); + const tmp = `${cacheFile}.tmp.${process.pid}`; + writeFileSync(tmp, slug, "utf-8"); + renameSync(tmp, cacheFile); + } catch { + // best-effort cache; a miss only costs a re-derive on the next call + } + return slug; +} + +/** Windows cannot exec an extensionless `#!/usr/bin/env bash` script (no shebang, no + * PATHEXT match for an explicit path), so gstack-slug spawns ENOENT there. */ +export const NEEDS_NATIVE_SLUG_ON_WINDOWS = process.platform === "win32"; + +/** + * Resolve the project slug via the `gstack-slug` helper (parses `SLUG=...`). + * + * On Windows that spawn fails ENOENT (see NEEDS_NATIVE_SLUG_ON_WINDOWS) and `r.stdout` + * is undefined — the same class of hazard as the gbrain shim spawns in lib/gbrain-exec.ts + * (#1731). Returning the literal "unknown" filed every decision under + * ~/.gstack/projects/unknown/ — one bucket shared by every project on the machine — + * while the bash-side Context Recovery preamble resolved the real slug, found no + * decisions.active.json there, and skipped through a bare `if [ -f … ]` with no else. + * + * Nothing failed, for ten days: BOTH decision bins (log and search) missed identically, + * so writes and searches stayed consistent with each other, and the only component that + * resolved correctly was silent by design. + * + * `shell: true` is NOT the fix here, unlike #1731: cmd.exe cannot run a bash script + * either. Nor is re-spawning through `bash` — on Windows that frequently resolves to + * WSL, whose $HOME and /mnt/c paths yield a different slug AND a different cache + * directory, trading one split store for another. + * + * POSIX behaviour is unchanged: the fallback is win32-only, where the previous result + * was unconditionally wrong and so has nothing to regress. + */ export function resolveSlug(slugBinPath: string): string { const r = spawnSync(slugBinPath, { encoding: "utf-8" }); const m = (r.stdout || "").match(/^SLUG=(.+)$/m); - return m ? m[1].trim() : "unknown"; + if (m) return m[1].trim(); + if (NEEDS_NATIVE_SLUG_ON_WINDOWS) return slugFromEnvironment(); + return "unknown"; } /** Current git branch, or undefined on detached HEAD / outside a repo. */ diff --git a/test/bin-context-windows-slug.test.ts b/test/bin-context-windows-slug.test.ts new file mode 100644 index 000000000..47f05b8da --- /dev/null +++ b/test/bin-context-windows-slug.test.ts @@ -0,0 +1,113 @@ +import { describe, test, expect, beforeEach, afterEach } from "bun:test"; +import { spawnSync } from "child_process"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { + toMsysPath, + slugFromEnvironment, + resolveSlug, + NEEDS_NATIVE_SLUG_ON_WINDOWS, +} from "../lib/bin-context"; + +const ROOT = path.resolve(import.meta.dir, ".."); +const read = (rel: string) => fs.readFileSync(path.join(ROOT, rel), "utf-8"); + +let tmp: string; +beforeEach(() => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), "gstack-slug-")); }); +afterEach(() => { try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {} }); + +/** + * Windows cannot exec bin/gstack-slug -- a `#!/usr/bin/env bash` script with no file + * extension -- so spawnSync fails ENOENT and resolveSlug used to return the literal + * string "unknown". Every decision on the machine landed in one shared + * ~/.gstack/projects/unknown/ bucket, while the bash-side Context Recovery preamble + * resolved the real slug and silently found nothing there. + * + * These exercise the native fallback on EVERY platform (it is only the *gating* that + * is win32-specific), so macOS/Linux CI catches a regression that would otherwise + * only ever surface on a Windows user's disk. + */ +describe("native slug fallback mirrors bin/gstack-slug", () => { + test("toMsysPath reproduces the git-bash cache key", () => { + // gstack-slug does: CACHE_KEY=$(printf '%s' "$(pwd)" | tr '/' '_') + // and git-bash `pwd` reports C:\Users\j\foo as /c/Users/j/foo. + expect(toMsysPath("C:\\Users\\j\\foo")).toBe("/c/Users/j/foo"); + expect(toMsysPath("D:/Work/Repo")).toBe("/d/Work/Repo"); + expect(toMsysPath("/already/posix")).toBe("/already/posix"); + // The cache FILENAME is the real contract: + expect(toMsysPath("C:\\Users\\j\\foo").replace(/\//g, "_")).toBe("_c_Users_j_foo"); + }); + + test("step 1: a cached slug wins over everything else", () => { + const cwd = path.join(tmp, "proj"); + fs.mkdirSync(cwd); + const cacheDir = path.join(tmp, "home", "slug-cache"); + fs.mkdirSync(cacheDir, { recursive: true }); + fs.writeFileSync(path.join(cacheDir, toMsysPath(cwd).replace(/\//g, "_")), "cached-wins"); + expect(slugFromEnvironment(path.join(tmp, "home"), cwd)).toBe("cached-wins"); + }); + + test("step 2: derives owner-repo from the git remote, https and ssh alike", () => { + for (const [url, want] of [ + ["https://github.com/acme/Widget.git", "acme-Widget"], + ["git@github.com:acme/Widget.git", "acme-Widget"], + ["https://gitlab.com/acme/Widget", "acme-Widget"], + ] as const) { + const cwd = fs.mkdtempSync(path.join(tmp, "repo-")); + spawnSync("git", ["init", "-q"], { cwd }); + spawnSync("git", ["remote", "add", "origin", url], { cwd }); + expect(slugFromEnvironment(path.join(tmp, "home2"), cwd)).toBe(want); + } + }); + + test("step 3: falls back to the sanitized directory name", () => { + // `tr -cd 'a-zA-Z0-9._-'` DELETES disallowed characters rather than replacing them. + const cwd = path.join(tmp, "My Proj+v2"); + fs.mkdirSync(cwd); + expect(slugFromEnvironment(path.join(tmp, "home3"), cwd)).toBe("MyProjv2"); + }); + + test("the resolved slug is cached back, as the shell script does", () => { + const cwd = path.join(tmp, "cacheme"); + fs.mkdirSync(cwd); + const home = path.join(tmp, "home4"); + const slug = slugFromEnvironment(home, cwd); + const key = path.join(home, "slug-cache", toMsysPath(cwd).replace(/\//g, "_")); + expect(fs.existsSync(key)).toBe(true); + // no trailing newline: gstack-slug writes with printf '%s' + expect(fs.readFileSync(key, "utf-8")).toBe(slug); + }); + + test("never returns the empty string", () => { + expect(slugFromEnvironment(path.join(tmp, "h"), tmp).length).toBeGreaterThan(0); + }); +}); + +describe("the fallback stays win32-gated", () => { + // Static tripwire in the style of gbrain-spawn-windows-shell.test.ts: POSIX CI + // cannot observe the Windows branch at runtime, so pin the gate itself. Removing + // it would silently change macOS/Linux behaviour, which today is byte-identical. + test("NEEDS_NATIVE_SLUG_ON_WINDOWS is platform-gated", () => { + expect(read("lib/bin-context.ts")).toMatch( + /export const NEEDS_NATIVE_SLUG_ON_WINDOWS\s*=\s*process\.platform === "win32"/, + ); + expect(NEEDS_NATIVE_SLUG_ON_WINDOWS).toBe(process.platform === "win32"); + }); + + test("resolveSlug no longer returns a bare literal on a failed spawn", () => { + const src = read("lib/bin-context.ts"); + expect(src).not.toMatch(/return m \? m\[1\]\.trim\(\) : "unknown";/); + expect(src).toMatch(/if \(NEEDS_NATIVE_SLUG_ON_WINDOWS\) return slugFromEnvironment\(\);/); + }); + + test("a spawn that cannot run resolves to a real slug, not 'unknown'", () => { + // The exact production failure: the helper path does not exist / cannot exec. + const got = resolveSlug(path.join(tmp, "definitely-not-a-real-bin")); + if (NEEDS_NATIVE_SLUG_ON_WINDOWS) { + expect(got).not.toBe("unknown"); + } else { + expect(got).toBe("unknown"); // POSIX behaviour deliberately unchanged + } + }); +}); From 13671be1490eca65344ed02219dcdd0906819c72 Mon Sep 17 00:00:00 2001 From: H M Ibtihal Utsho Date: Fri, 14 Aug 2026 00:10:17 -0400 Subject: [PATCH 2/2] fix(artifacts): sync the decision store, which no allowlist glob matched gstack-decision-log enqueues projects//decisions.jsonl after every write, but none of the 16 managed globs matched it, so compute_paths_to_stage rejected every one at its "must match at least one allowlist glob" check. The writer and the syncer disagreed silently: enabling artifacts sync backed up learnings, plans, designs and timelines -- everything except the durable decision ledger -- and nothing reported a miss, because a dropped path prints exactly what a synced one does when the queue is otherwise empty. Add the three decisions.* globs and class them artifact so they also sync in artifacts-only mode. The test reads the heredocs out of the script rather than executing it: gstack-artifacts-init.test.ts drives the real script through #!/bin/bash shims and a colon-separated PATH, so it cannot run on Windows -- the platform where the companion slug bug bit. --- bin/gstack-artifacts-init | 11 ++++ test/artifacts-allowlist-decisions.test.ts | 66 ++++++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 test/artifacts-allowlist-decisions.test.ts diff --git a/bin/gstack-artifacts-init b/bin/gstack-artifacts-init index b8bfe830c..c35b63382 100755 --- a/bin/gstack-artifacts-init +++ b/bin/gstack-artifacts-init @@ -240,6 +240,14 @@ projects/*/*-design-*.md projects/*/*-test-plan-*.md projects/*/*-eng-review-test-plan-*.md projects/*/timeline.jsonl +# The decision store. gstack-decision-log enqueues projects//decisions.jsonl +# after EVERY write, but no glob above matched it, so compute_paths_to_stage rejected +# all of them at its "must match at least one allowlist glob" check -- a writer +# enqueueing a path the syncer is guaranteed to drop. Without these the durable +# decision ledger never leaves the machine, on any platform. +projects/*/decisions.jsonl +projects/*/decisions.active.json +projects/*/decisions.archive.jsonl retros/*.md developer-profile.json builder-journey.md @@ -267,6 +275,9 @@ cat > "$GSTACK_HOME/.brain-privacy-map.json" <<'EOF' {"pattern": "projects/*/*-design-*.md", "class": "artifact"}, {"pattern": "projects/*/*-test-plan-*.md", "class": "artifact"}, {"pattern": "projects/*/*-eng-review-test-plan-*.md", "class": "artifact"}, + {"pattern": "projects/*/decisions.jsonl", "class": "artifact"}, + {"pattern": "projects/*/decisions.active.json", "class": "artifact"}, + {"pattern": "projects/*/decisions.archive.jsonl", "class": "artifact"}, {"pattern": "retros/*.md", "class": "artifact"}, {"pattern": "builder-journey.md", "class": "artifact"}, {"pattern": "projects/*/timeline.jsonl", "class": "behavioral"}, diff --git a/test/artifacts-allowlist-decisions.test.ts b/test/artifacts-allowlist-decisions.test.ts new file mode 100644 index 000000000..7908cd28d --- /dev/null +++ b/test/artifacts-allowlist-decisions.test.ts @@ -0,0 +1,66 @@ +import { describe, test, expect } from "bun:test"; +import * as fs from "fs"; +import * as path from "path"; + +const ROOT = path.resolve(import.meta.dir, ".."); +const INIT = fs.readFileSync(path.join(ROOT, "bin", "gstack-artifacts-init"), "utf-8"); + +/** Pull a quoted heredoc body out of gstack-artifacts-init by target filename. */ +function heredoc(target: string): string { + const re = new RegExp(`cat > "\\$GSTACK_HOME/${target}" <<'EOF'\\n([\\s\\S]*?)\\nEOF\\n`); + const m = INIT.match(re); + if (!m) throw new Error(`heredoc for ${target} not found in gstack-artifacts-init`); + return m[1]; +} + +/** fnmatch.fnmatchcase semantics, as compute_paths_to_stage applies them: + * `*` does not cross a path separator. */ +function globToRe(g: string): RegExp { + return new RegExp("^" + g.split("*").map((s) => s.replace(/[.]/g, "[.]")).join("[^/]*") + "$"); +} + +const DECISION_PATHS = [ + "projects/acme-widget/decisions.jsonl", + "projects/acme-widget/decisions.active.json", + "projects/acme-widget/decisions.archive.jsonl", +]; + +/** + * gstack-decision-log:40 enqueues projects//decisions.jsonl after EVERY write, + * but no managed glob matched it, so compute_paths_to_stage rejected all of them at + * its "must match at least one allowlist glob" check. The writer and the syncer + * disagreed silently: turning artifacts sync on backed up learnings, plans, designs + * and timelines -- everything EXCEPT the durable decision ledger -- and nothing + * anywhere reported a miss, because a dropped path prints exactly what a synced one + * does when the queue is otherwise empty. + * + * Source-level rather than end-to-end: gstack-artifacts-init.test.ts drives the real + * script through #!/bin/bash shims and a colon-separated PATH, so it cannot run on + * Windows -- which is the platform where this bug bit. + */ +describe("the artifacts allowlist covers the decision store", () => { + const globs = heredoc("\\.brain-allowlist") + .split("\n") + .map((l) => l.trim()) + .filter((l) => l && !l.startsWith("#")); + + test("every decisions.* path matches at least one allowlist glob", () => { + for (const p of DECISION_PATHS) { + expect({ p, matched: globs.some((g) => globToRe(g).test(p)) }).toEqual({ p, matched: true }); + } + }); + + test("decisions.* are class artifact, so they sync in artifacts-only mode too", () => { + const map = JSON.parse(heredoc("\\.brain-privacy-map\\.json")); + for (const p of DECISION_PATHS) { + const hit = map.find((e: { pattern: string; class: string }) => globToRe(e.pattern).test(p)); + expect({ p, cls: hit?.class }).toEqual({ p, cls: "artifact" }); + } + }); + + test("the allowlist still ends with the user-additions marker", () => { + // Additions below it survive re-init; a glob added above would be silently + // overwritten the next time gstack-artifacts-init runs. + expect(heredoc("\\.brain-allowlist").trimEnd()).toMatch(/# ---- USER ADDITIONS BELOW ----/); + }); +});