diff --git a/.gitattributes b/.gitattributes
index 65dc0f77a..397af035f 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -24,6 +24,18 @@ setup text eol=lf
bin/* text eol=lf
**/scripts/* text eol=lf
+# Nested executables (#3110). A pattern containing a slash is anchored at the
+# repository root, so `bin/*` above covers only the top-level bin/. These are
+# bash or sh entry points too; a Windows checkout wrote them with CRLF, and a
+# Linux bash reading that checkout (a synced tree, WSL, a bind mount) failed
+# with `set: pipefail: invalid option name`. setup's CRLF repair rewrites
+# copies an older checkout already left with CRLF.
+**/bin/* text eol=lf
+hosts/*/hooks/* text eol=lf
+lib/cso/images/* text eol=lf
+scripts/app/* text eol=lf
+browse/test/fixtures/mock-claude/* text eol=lf
+
# Dockerfiles build on Linux, and test/ci-image-cli-pin.test.ts joins their
# `\`-continued RUN lines with a `\\\n` match; CRLF left each RUN split.
Dockerfile* text eol=lf
@@ -42,6 +54,7 @@ Dockerfile* text eol=lf
*.gif binary
*.ico binary
*.pdf binary
+*.icns binary
# The committed diagram-render bundle is hash-pinned (BUILD_INFO sha256);
# a CRLF rewrite on Windows checkout would break the drift test and change
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 29aea90fe..4733c54fc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,26 @@
# Changelog
+## [1.91.90.0] - 2026-10-11
+
+**Windows checkouts stop breaking gstack's nested shell scripts on Linux, the timeline Stop hook repairs on Windows instead of timing out every turn, and the `/setup-gbrain` smoke test stops piping into `gbrain put`.**
+
+### What this means for you
+
+- **Nested scripts are LF everywhere (#3110).** `.gitattributes` pinned only the top-level `bin/` to LF, so Git for Windows (`core.autocrlf=true`) wrote skill `bin/` helpers, the Claude hook shims, `lib/cso/images/*` and `scripts/app/*` with CRLF. A Linux bash reading that checkout (a synced tree, WSL, a bind mount) failed with `set: pipefail: invalid option name`, and `/autoplan`'s publication hook exited 2, blocking every phase entry. Every tracked `#!` file now resolves to `eol=lf`.
+- **`./setup` repairs existing CRLF copies.** Pulling the new rule does not rewrite a file whose content did not change, so setup finds tracked files whose rule says LF but whose copy has CRLF and checks them out again as LF. A file you edited is left alone and named, with the command that rewrites it and keeps your edit (`docs/troubleshooting.md#setup-crlf-repair`).
+- **The timeline Stop hook returns at once and repairs (#3103).** Through Git Bash, `gstack-slug` took 2.2-2.8 s against the hook's 2 s budget, so on Windows the hook spent about 2.5 s on every turn and repaired nothing. It now reads the slug from gstack-slug's cache in-process (`GSTACK_PROJECT_SLUG` still wins). On a miss a hidden background process resolves the slug and repairs, one at a time under a lock, and a directory it cannot resolve is retried after 5 minutes instead of every turn.
+- **The `/setup-gbrain` smoke test writes the page to a temp file (#1675)** and runs `gbrain put "$SLUG" < file`, the shape gstack's other `gbrain put` instructions already use.
+
+### For contributors
+
+- `test/gitattributes-eol.test.ts` checks `git check-attr eol` for every tracked `#!` file and that no file git stores as binary is marked text (`*.icns` is now `binary`, since `scripts/app/*` would otherwise cover the app icon).
+- `test/setup-crlf-repair.test.ts` builds the broken state with a real `core.autocrlf=true` clone, runs setup's repair under the install registry lock, checks the repaired bytes and runs the hook.
+- `test/timeline-stop-hook-slug.test.ts` runs a copy of the hook against a slow stub `gstack-slug` and is force-included in the Windows lane. Native Windows latency is measured only if that lane runs it.
+
+Thanks @Mr-B-1 (#3110)
+Thanks @Peaky8linders (#3103)
+Thanks @ElliotDrel (#1675)
+
## [1.91.89.0] - 2026-10-11
**Windows installs stuck on the Git Bash hook false positive get a one-line recovery, the auto-updater stops trusting only its own checker, and setup can no longer delete the checkout it runs from.**
diff --git a/VERSION b/VERSION
index 563ace4b6..ed6c016a6 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.91.89.0
+1.91.90.0
diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md
index a79d4df67..d5a98806a 100644
--- a/agents-digest/gstack-AGENTS.md
+++ b/agents-digest/gstack-AGENTS.md
@@ -1,4 +1,4 @@
-# gstack digest v1.91.89.0 — regenerate/re-copy after upgrading gstack
+# gstack digest v1.91.90.0 — regenerate/re-copy after upgrading gstack
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
for agent hosts without a full skill install. The full skills add workflows,
diff --git a/bin/gstack-gbrain-source-wireup b/bin/gstack-gbrain-source-wireup
index 11248bf28..504e95d51 100755
--- a/bin/gstack-gbrain-source-wireup
+++ b/bin/gstack-gbrain-source-wireup
@@ -305,7 +305,7 @@ do_wireup() {
case "$ew_rc" in
0) : ;; # success
2)
- [ "$STRICT" = "1" ] && die "no $GSTACK_HOME/.git; run /setup-gbrain Step 7 (gstack-brain-init) first" 2
+ [ "$STRICT" = "1" ] && die "no $GSTACK_HOME/.git; run /setup-gbrain Step 7 (gstack-artifacts-init) first" 2
warn "no $GSTACK_HOME/.git; skipping (benign skip)"
exit 0
;;
diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md
index 45b4be6ef..dad19862d 100644
--- a/docs/troubleshooting.md
+++ b/docs/troubleshooting.md
@@ -2345,3 +2345,32 @@ owner decides` line whose decision id is still pending, so it was left as is.
**Fix.** Answer the gate for that id, then rerun `gstack-owner-brief close`;
only decided lines are rewritten, each to its chosen option and the date.
+
+
+### `warning: left locally edited file(s) with CRLF line endings that gstack pins to LF`
+
+**Meaning.** Git for Windows checks out with `core.autocrlf=true`, so gstack's
+nested shell scripts (skill `bin/` helpers, the Claude hook shims,
+`lib/cso/images/*`, `scripts/app/*`) were written with CRLF until
+`.gitattributes` pinned them to LF (#3110). Git Bash runs them anyway, but a
+Linux bash reading the same checkout (a synced tree, WSL, a bind mount) fails
+with `set: pipefail: invalid option name`, and a hook that fails that way
+blocks the tool call it guards. Pulling the new rule does not fix existing
+copies, because git never rewrites a file whose content did not change. Setup
+finds those files with one `git ls-files --eol` pass and checks them out again
+as LF. It leaves a file you edited locally alone and names it in this warning.
+The same section covers `skipped the CRLF repair` (another setup held the
+install registry lock) and `the CRLF repair could not rewrite every file`.
+
+**Fix.** For edited files, stash and restore them; git writes them back with LF
+and keeps your edits:
+
+```bash
+cd ~/.claude/skills/gstack && git stash && git stash pop
+```
+
+If the repair was skipped, re-run `./setup` once the other setup finishes. If
+it failed, `git checkout -- . && ./setup` in the checkout discards local edits
+and rewrites every file. Use your install directory if it is not
+`~/.claude/skills/gstack` (`./setup --status` prints it). Expected result: the
+next `./setup` prints no CRLF warning.
diff --git a/hosts/claude/hooks/timeline-stop-hook.ts b/hosts/claude/hooks/timeline-stop-hook.ts
index f0e658e57..7ccd62ba3 100755
--- a/hosts/claude/hooks/timeline-stop-hook.ts
+++ b/hosts/claude/hooks/timeline-stop-hook.ts
@@ -27,6 +27,19 @@
* inside the window has its completion inside the window too: the window
* can never fabricate a dangling entry, and idempotency holds.
*
+ * Slug resolution (#3103): the fast path reads the slug in-process, from
+ * GSTACK_PROJECT_SLUG or gstack-slug's versioned cache entry
+ * (readVersionedSlugCache, the question-preference hook's reader); every skill
+ * preamble runs gstack-slug, so a project that used gstack is a hit. On a
+ * miss the hook spawns a detached copy of itself (`--repair `) and
+ * returns: through Git Bash on Windows, gstack-slug alone took 2.2-2.8 s, past
+ * the 2 s budget, so the old synchronous spawn timed out on every Stop and
+ * repaired nothing. The child runs gstack-slug with a long timeout (which
+ * writes the cache) and repairs. Every repair, foreground or child, holds an
+ * exclusive TTL lock in the state root, so one runs at a time. A per-cwd miss
+ * marker keeps a directory gstack-slug cannot resolve from spawning a child
+ * on every turn. windowsHide keeps both spawns from opening a console window.
+ *
* Correlation limits, on purpose: the preamble's session id is a shell-local
* "$$-epoch", not the Claude session id this hook receives, so entries can't
* be attributed to THIS session specifically. Closing every dangling entry in
@@ -34,14 +47,20 @@
* mid-skill in the same project may get its entry closed early, which shows
* up as a traceable source:"stop-hook" completion rather than a silent leak.
*/
+import { spawn } from 'child_process';
+import { createHash } from 'crypto';
import * as fs from 'fs';
-import * as os from 'os';
import * as path from 'path';
+import { fileURLToPath } from 'url';
import { runBin } from './spawn-bin';
import { resolveStateRoot } from '../../../lib/state-root';
+import { readVersionedSlugCache } from '../../../lib/bin-context';
import { logHookError as sharedLogHookError } from './hook-log';
const DEADLINE_MS = 2000;
+const REPAIR_SLUG_TIMEOUT_MS = 30_000;
+const LOCK_TTL_MS = 60_000;
+const MISS_TTL_MS = 5 * 60_000;
const MAX_TIMELINE_BYTES = 10 * 1024 * 1024;
const TAIL_WINDOW_BYTES = 256 * 1024;
const startedAt = Date.now();
@@ -82,33 +101,134 @@ interface TimelineEntry {
ts?: string;
}
-function main(): void {
- let cwd = process.cwd();
+function stateDir(): string {
+ return path.join(resolveStateRoot(), 'timeline-stop-hook');
+}
+
+function lockPath(): string {
+ return path.join(stateDir(), 'repair.lock');
+}
+
+function isFresh(file: string, ttlMs: number): boolean {
+ try {
+ return Date.now() - fs.statSync(file).mtimeMs < ttlMs;
+ } catch {
+ return false;
+ }
+}
+
+/** Exclusive create; a lock older than LOCK_TTL_MS belongs to a dead repair and is taken over. */
+function acquireRepairLock(): boolean {
+ const lock = lockPath();
+ fs.mkdirSync(path.dirname(lock), { recursive: true });
+ for (let attempt = 0; attempt < 2; attempt++) {
+ try {
+ fs.writeFileSync(lock, `${process.pid}\n`, { flag: 'wx' });
+ return true;
+ } catch (err) {
+ if ((err as NodeJS.ErrnoException).code !== 'EEXIST' || isFresh(lock, LOCK_TTL_MS)) return false;
+ try {
+ fs.unlinkSync(lock);
+ } catch {
+ // another process took it over first; the retry decides
+ }
+ }
+ }
+ return false;
+}
+
+function releaseRepairLock(): void {
+ try {
+ if (fs.readFileSync(lockPath(), 'utf8').trim() === String(process.pid)) fs.unlinkSync(lockPath());
+ } catch {
+ // already gone
+ }
+}
+
+function withRepairLock(fn: () => void): void {
+ if (!acquireRepairLock()) return; // a repair is running; the next Stop picks up whatever it left
+ try {
+ fn();
+ } finally {
+ releaseRepairLock();
+ }
+}
+
+/** GSTACK_PROJECT_SLUG, sanitized as gstack-slug does; an unusable pin is ignored. */
+function slugOverride(): string | undefined {
+ const slug = (process.env.GSTACK_PROJECT_SLUG || '').replace(/[^a-zA-Z0-9._-]/g, '');
+ return slug && slug !== '.' && slug !== '..' ? slug : undefined;
+}
+
+function readStopCwd(): string {
try {
const stdin = fs.readFileSync(0, 'utf8');
if (stdin.trim()) {
const payload = JSON.parse(stdin) as { cwd?: string };
- if (payload.cwd && fs.existsSync(payload.cwd)) cwd = payload.cwd;
+ if (payload.cwd && fs.existsSync(payload.cwd)) return payload.cwd;
}
} catch {
// Bad/absent stdin: fall through with process.cwd() — repair is still valid.
}
+ return process.cwd();
+}
- // Resolve the project slug the same way the preamble did (GSTACK_PROJECT_SLUG
- // override, project-root walk, remote-derived slug).
- let slug = '';
- try {
- const r = runBin('gstack-slug', [], { cwd, encoding: 'utf8', timeout: DEADLINE_MS });
- const m = (r.stdout ?? '').toString().match(/^SLUG=([A-Za-z0-9._-]+)$/m);
- if (m) slug = m[1];
- } catch {
- // fall through
- }
- if (!slug) {
- logHookError('could not resolve project slug — nothing repaired');
+/**
+ * Cache miss: hand the slow resolve to a detached copy of this hook and return
+ * now. Skipped while a repair holds the lock, and for MISS_TTL_MS after the
+ * last spawn for this cwd (the marker is written before the spawn, so a child
+ * that fails to resolve is not respawned every turn).
+ */
+function spawnRepair(cwd: string): void {
+ if (isFresh(lockPath(), LOCK_TTL_MS)) return;
+ const marker = path.join(stateDir(), 'miss', createHash('sha256').update(cwd).digest('hex').slice(0, 16));
+ if (isFresh(marker, MISS_TTL_MS)) return;
+ fs.mkdirSync(path.dirname(marker), { recursive: true });
+ fs.writeFileSync(marker, `${cwd}\n`);
+ const child = spawn(process.execPath, [fileURLToPath(import.meta.url), '--repair', cwd], {
+ detached: true,
+ stdio: 'ignore',
+ windowsHide: true,
+ });
+ child.on('error', (err) => logHookError(`could not start the background repair: ${err.message}`));
+ child.unref();
+}
+
+/** The detached child: resolve the slug with gstack-slug (which writes the cache), then repair. */
+function backgroundRepair(cwd: string): void {
+ withRepairLock(() => {
+ const r = runBin('gstack-slug', [], { cwd, encoding: 'utf8', timeout: REPAIR_SLUG_TIMEOUT_MS, windowsHide: true });
+ const slug = (r.stdout ?? '').toString().match(/^SLUG=([A-Za-z0-9._-]+)$/m)?.[1];
+ if (!slug) {
+ const why = r.error ? (r.error as NodeJS.ErrnoException).code ?? r.error.message : `exit ${r.status}`;
+ logHookError(`could not resolve project slug for ${cwd} (gstack-slug: ${why}) — nothing repaired; retried after ${MISS_TTL_MS / 60_000} min`);
+ return;
+ }
+ repairTimeline(slug, startedAt + LOCK_TTL_MS);
+ });
+}
+
+function main(): void {
+ if (process.argv[2] === '--repair' && process.argv[3]) {
+ backgroundRepair(process.argv[3]);
return;
}
+ const cwd = readStopCwd();
+ const slug = slugOverride() ?? readVersionedSlugCache(resolveStateRoot(), cwd);
+ if (!slug) {
+ spawnRepair(cwd);
+ return;
+ }
+ withRepairLock(() => repairTimeline(slug, startedAt + DEADLINE_MS));
+}
+/**
+ * Append a completion for every dangling "started" in the project's timeline.
+ * `deadline` (epoch ms) is re-checked before the write: the foreground hook's
+ * 2 s budget, or for the background child the lock TTL, so a write never lands
+ * after the lock could have been taken over.
+ */
+function repairTimeline(slug: string, deadline: number): void {
const timelinePath = path.join(resolveStateRoot(), 'projects', slug, 'timeline.jsonl');
let stat: fs.Stats;
try {
@@ -171,8 +291,8 @@ function main(): void {
}
if (dangling.length === 0) return;
- if (Date.now() - startedAt > DEADLINE_MS) {
- logHookError('internal 2s budget exhausted before write — skipped (fail-open)');
+ if (Date.now() > deadline) {
+ logHookError('time budget exhausted before write — skipped (fail-open)');
return;
}
diff --git a/package.json b/package.json
index d66883363..8b6d55e4b 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "gstack",
- "version": "1.91.89",
+ "version": "1.91.90",
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
"license": "MIT",
"type": "module",
diff --git a/scripts/lib/windows-curation.ts b/scripts/lib/windows-curation.ts
index 6a3805002..1e6e63fe2 100644
--- a/scripts/lib/windows-curation.ts
+++ b/scripts/lib/windows-curation.ts
@@ -370,6 +370,16 @@ export const KNOWN_WINDOWS_SAFE: Array<{ file: string; reason: string }> = [
// orphan leak it pins was reported on Windows (#2019).
reason: 'spawns terminal-agent (no Playwright), not the browse server; owner-orphan leak is a Windows defect',
},
+ {
+ file: 'test/timeline-stop-hook-slug.test.ts',
+ // Trips the "spawns bin/ shebang script" pattern via the fixture's
+ // path.join(tree, 'bin', 'gstack-slug') stub, but the hook runs through
+ // Bun argv and reaches the stub through runBin, which hands it to Git
+ // Bash on win32. #3103 is a Windows latency defect: this lane is the one
+ // place the hook's return-within-budget assertion meets real Windows
+ // process startup.
+ reason: '#3103: the hook runs through Bun argv and its gstack-slug stub through runBin (Git Bash on win32); the return-within-budget timing must run on windows-latest',
+ },
];
/**
diff --git a/setup b/setup
index bfe9217d6..ceb9a0849 100755
--- a/setup
+++ b/setup
@@ -387,6 +387,59 @@ _persist_claude_model() {
# ─── Quiet mode helper ────────────────────────────────────────
log() { [ "$QUIET" -eq 0 ] && echo "$@" || true; }
+# _repair_crlf_checkout — #3110: a checkout made before an `eol=lf` rule
+# existed (Git for Windows sets core.autocrlf=true) keeps its CRLF copies,
+# because git never rewrites a stat-clean file; `add --renormalize`,
+# `checkout --` and `checkout-index -f` all leave one alone. One
+# `git ls-files --eol` pass finds tracked files whose rule says LF but whose
+# working copy has CRLF. Locally edited ones are named and left alone; the
+# rest are removed and checked out again in one batch. Runs under the install
+# registry lock, the one lock every setup takes, so two setups never race on
+# the same files.
+_repair_crlf_checkout() {
+ local dir="$SOURCE_GSTACK_DIR" line info p e rc=0
+ local stale=() edited=() repair=()
+ [ -e "$dir/.git" ] || return 0
+ command -v git >/dev/null 2>&1 || return 0
+ while IFS= read -r -d '' line; do
+ info="${line%% *}"
+ case "$info" in *eol=lf*) ;; *) continue ;; esac
+ case "$info" in *w/crlf*|*w/mixed*) stale+=("${line#* }") ;; esac
+ done < <(git -C "$dir" ls-files --eol -z 2>/dev/null)
+ [ "${#stale[@]}" -gt 0 ] || return 0
+ if ! _gstack_registry_lock; then
+ echo " warning: skipped the CRLF repair of ${#stale[@]} file(s) in $dir: another setup holds the install registry lock." >&2
+ echo " Fix: re-run ./setup once the other setup finishes (cd $dir && ./setup). See docs/troubleshooting.md#setup-crlf-repair" >&2
+ return 0
+ fi
+ while IFS= read -r -d '' line; do
+ edited+=("${line:3}")
+ done < <(git -C "$dir" --literal-pathspecs status --porcelain=v1 -z --untracked-files=no --no-renames -- "${stale[@]}" 2>/dev/null)
+ for p in "${stale[@]}"; do
+ for e in ${edited[@]+"${edited[@]}"}; do [ "$e" = "$p" ] && continue 2; done
+ repair+=("$p")
+ done
+ if [ "${#repair[@]}" -gt 0 ]; then
+ for p in "${repair[@]}"; do rm -f "$dir/$p" || rc=1; done
+ [ "$rc" -ne 0 ] || printf '%s\0' "${repair[@]}" \
+ | git -C "$dir" --literal-pathspecs checkout --pathspec-from-file=- --pathspec-file-nul >/dev/null 2>&1 || rc=1
+ if [ "$rc" -eq 0 ]; then
+ log " Rewrote ${#repair[@]} file(s) checked out with CRLF line endings as LF (#3110)."
+ else
+ echo " warning: the CRLF repair could not rewrite every file in $dir (rm or git checkout failed)." >&2
+ echo " Fix: cd $dir && git checkout -- . && ./setup See docs/troubleshooting.md#setup-crlf-repair" >&2
+ fi
+ fi
+ if [ "${#edited[@]}" -gt 0 ]; then
+ echo " warning: left ${#edited[@]} locally edited file(s) with CRLF line endings that gstack pins to LF; a Linux bash cannot run them:" >&2
+ printf ' %s\n' "${edited[@]}" >&2
+ echo " Why: setup rewrites only unmodified files, so your edits are kept." >&2
+ echo " Fix (keeps your edits, rewrites them as LF): cd $dir && git stash && git stash pop See docs/troubleshooting.md#setup-crlf-repair" >&2
+ fi
+ _gstack_registry_unlock
+}
+[ -n "${GSTACK_SETUP_REFRESH_CHILD:-}" ] || _repair_crlf_checkout
+
# ─── --refresh-registered: refresh exactly the registered installs (#1925) ───
# What /gstack-upgrade and the team-mode auto-update run. Each install this
# checkout registered is refreshed by running setup for that one host, in its
diff --git a/setup-gbrain/SKILL.md b/setup-gbrain/SKILL.md
index 06c557d01..f24634b53 100644
--- a/setup-gbrain/SKILL.md
+++ b/setup-gbrain/SKILL.md
@@ -827,12 +827,16 @@ Do NOT print the actual token in the curl command — leave the placeholder
```bash
SLUG="setup-gbrain-smoke-test-$(date +%s)"
-echo "Set up on $(date). Smoke test for /setup-gbrain." | gbrain put "$SLUG"
-gbrain search "smoke test" | grep -i "$SLUG"
+SMOKE_PAGE=$(mktemp "${TMPDIR:-/tmp}/gstack-gbrain-smoke.XXXXXX")
+echo "Set up on $(date). Smoke test for /setup-gbrain." > "$SMOKE_PAGE"
+gbrain put "$SLUG" < "$SMOKE_PAGE"; PUT_RC=$?
+rm -f "$SMOKE_PAGE"
+[ "$PUT_RC" -eq 0 ] && gbrain search "smoke test" | grep -i "$SLUG"
```
-Confirms the round trip. On failure, surface `gbrain doctor --json` output
-and STOP with a NEEDS_CONTEXT escalation.
+Confirms the round trip. The page goes in from a temp file, not a pipe: on
+Windows, gbrain failed to read a piped page. On failure, surface
+`gbrain doctor --json` output and STOP with a NEEDS_CONTEXT escalation.
---
diff --git a/setup-gbrain/SKILL.md.tmpl b/setup-gbrain/SKILL.md.tmpl
index 3af5f03be..f49b5f5bb 100644
--- a/setup-gbrain/SKILL.md.tmpl
+++ b/setup-gbrain/SKILL.md.tmpl
@@ -483,12 +483,16 @@ Do NOT print the actual token in the curl command — leave the placeholder
```bash
SLUG="setup-gbrain-smoke-test-$(date +%s)"
-echo "Set up on $(date). Smoke test for /setup-gbrain." | gbrain put "$SLUG"
-gbrain search "smoke test" | grep -i "$SLUG"
+SMOKE_PAGE=$(mktemp "${TMPDIR:-/tmp}/gstack-gbrain-smoke.XXXXXX")
+echo "Set up on $(date). Smoke test for /setup-gbrain." > "$SMOKE_PAGE"
+gbrain put "$SLUG" < "$SMOKE_PAGE"; PUT_RC=$?
+rm -f "$SMOKE_PAGE"
+[ "$PUT_RC" -eq 0 ] && gbrain search "smoke test" | grep -i "$SLUG"
```
-Confirms the round trip. On failure, surface `gbrain doctor --json` output
-and STOP with a NEEDS_CONTEXT escalation.
+Confirms the round trip. The page goes in from a temp file, not a pipe: on
+Windows, gbrain failed to read a piped page. On failure, surface
+`gbrain doctor --json` output and STOP with a NEEDS_CONTEXT escalation.
---
diff --git a/test/gitattributes-eol.test.ts b/test/gitattributes-eol.test.ts
new file mode 100644
index 000000000..867c72245
--- /dev/null
+++ b/test/gitattributes-eol.test.ts
@@ -0,0 +1,83 @@
+/**
+ * .gitattributes line endings for executables (#3110).
+ *
+ * Git for Windows checks out with core.autocrlf=true. A tracked script with no
+ * `eol` rule is written with CRLF there, and a Linux bash reading that
+ * checkout (a synced tree, WSL, a bind mount) fails on `set -o pipefail\r`.
+ * `bin/*` is anchored at the root, so nested bin/ dirs, the hook shims, the
+ * CSO image scripts and the app launcher had no rule. This pins the rules for
+ * every tracked file that starts with `#!`, and keeps the broad patterns from
+ * marking a binary file as text.
+ */
+import { describe, test, expect } from 'bun:test';
+import { spawnSync } from 'child_process';
+import * as fs from 'fs';
+import * as path from 'path';
+
+const ROOT = path.resolve(import.meta.dir, '..');
+
+function git(args: string[], input?: string): string {
+ const r = spawnSync('git', args, { cwd: ROOT, encoding: 'utf-8', input, timeout: 30_000, maxBuffer: 64 * 1024 * 1024 });
+ if (r.status !== 0) throw new Error(`git ${args.join(' ')} failed: ${r.stderr}`);
+ return r.stdout;
+}
+
+function trackedShebangFiles(): string[] {
+ return git(['ls-files', '-z']).split('\0').filter((f) => {
+ if (!f) return false;
+ const full = path.join(ROOT, f);
+ let fd: number | undefined;
+ try {
+ if (!fs.statSync(full).isFile()) return false;
+ fd = fs.openSync(full, 'r');
+ const head = Buffer.alloc(2);
+ return fs.readSync(fd, head, 0, 2, 0) === 2 && head.toString('latin1') === '#!';
+ } catch {
+ return false;
+ } finally {
+ if (fd !== undefined) fs.closeSync(fd);
+ }
+ });
+}
+
+describe('.gitattributes eol rules (#3110)', () => {
+ test('every tracked file that starts with #! resolves to eol=lf', () => {
+ const files = trackedShebangFiles();
+ expect(files.length).toBeGreaterThan(100);
+ const out = git(['check-attr', '-z', '--stdin', 'eol'], files.join('\0') + '\0').split('\0');
+ const missing: string[] = [];
+ for (let i = 0; i + 2 < out.length; i += 3) {
+ if (out[i + 2] !== 'lf') missing.push(`${out[i]} (eol: ${out[i + 2]})`);
+ }
+ expect(missing).toEqual([]);
+ });
+
+ test('the nested executables named in #3110 are covered', () => {
+ const named = [
+ 'autoplan/bin/phase-publication-hook',
+ 'plan-ceo-review/bin/mode-handoff-hook',
+ 'hosts/claude/hooks/timeline-stop-hook',
+ 'browse/bin/remote-slug',
+ 'lib/cso/images/entrypoint',
+ 'scripts/app/gstack-browser',
+ 'browse/test/fixtures/mock-claude/claude',
+ ];
+ const out = git(['check-attr', 'eol', '--', ...named]);
+ for (const f of named) expect(out).toContain(`${f}: eol: lf`);
+ });
+
+ test('no file git stores as binary is marked text by the broad patterns', () => {
+ const wronglyText = git(['ls-files', '--eol'])
+ .split('\n')
+ .filter((line) => line.startsWith('i/-text') && /\sattr\/text\b/.test(line));
+ expect(wronglyText).toEqual([]);
+ });
+
+ test('every shebang file is stored with LF in the index', () => {
+ const files = new Set(trackedShebangFiles());
+ const crlf = git(['ls-files', '--eol'])
+ .split('\n')
+ .filter((line) => /^i\/(crlf|mixed)\s/.test(line) && files.has(line.split('\t')[1] ?? ''));
+ expect(crlf).toEqual([]);
+ });
+});
diff --git a/test/gstack-gbrain-source-wireup.test.ts b/test/gstack-gbrain-source-wireup.test.ts
index 2fc906441..c792ff196 100644
--- a/test/gstack-gbrain-source-wireup.test.ts
+++ b/test/gstack-gbrain-source-wireup.test.ts
@@ -345,6 +345,16 @@ describe('gstack-gbrain-source-wireup — wireup mode', () => {
expect(r.status).toBe(0);
});
+ test('--strict with no .git: exits 2 and names the current init command (#1675)', () => {
+ fs.mkdirSync(tmpHome, { recursive: true });
+ fs.writeFileSync(path.join(tmpHome, '.gstack-brain-remote.txt'), 'git@github.com:user/gstack-brain-bob.git\n');
+ makeFakeGbrain({});
+ const r = run(['--strict']);
+ expect(r.status).toBe(2);
+ expect(r.stderr).toContain('run /setup-gbrain Step 7 (gstack-artifacts-init) first');
+ expect(r.stderr).not.toContain('brain-init');
+ });
+
test('source-id from --source-id flag overrides everything', () => {
setupGstackRepo('git@github.com:user/gstack-brain-different.git');
makeFakeGbrain({});
diff --git a/test/setup-crlf-repair.test.ts b/test/setup-crlf-repair.test.ts
new file mode 100644
index 000000000..0173fe0c3
--- /dev/null
+++ b/test/setup-crlf-repair.test.ts
@@ -0,0 +1,158 @@
+/**
+ * setup's CRLF repair (#3110).
+ *
+ * Git for Windows checks out with core.autocrlf=true, so a script with no
+ * `eol` rule was written with CRLF. Adding the rule later does not fix the
+ * copy: git never rewrites a stat-clean file whose blob did not change. The
+ * fixture makes that state with a real autocrlf=true clone, adds the rule
+ * upstream, pulls, and runs setup's _repair_crlf_checkout under the real
+ * install-registry lock. The repaired hook must have LF bytes and run under
+ * bash; a locally edited CRLF file is named in a warning and left alone.
+ */
+import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
+import { spawnSync } from 'child_process';
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+
+const ROOT = path.resolve(import.meta.dir, '..');
+const SETUP_SRC = fs.readFileSync(path.join(ROOT, 'setup'), 'utf-8');
+const HOOK_SHIM = fs.readFileSync(path.join(ROOT, 'autoplan', 'bin', 'phase-publication-hook'), 'utf-8');
+const HOOK_REL = 'autoplan/bin/phase-publication-hook';
+const EDITED_REL = 'autoplan/bin/edited-hook';
+
+function extractFn(name: string): string {
+ const start = SETUP_SRC.indexOf(`${name}() {`);
+ const end = SETUP_SRC.indexOf('\n}\n', start);
+ if (start < 0 || end < 0) throw new Error(`Could not locate ${name}() in setup`);
+ return SETUP_SRC.slice(start, end + 2);
+}
+
+let tmp: string;
+let upstream: string;
+let clone: string;
+let stateRoot: string;
+let env: Record;
+
+function run(cmd: string, args: string[], cwd: string, input?: string) {
+ return spawnSync(cmd, args, { cwd, env, input, encoding: 'utf-8', timeout: 30_000 });
+}
+
+function git(cwd: string, ...args: string[]): string {
+ const r = run('git', args, cwd);
+ if (r.status !== 0) throw new Error(`git ${args.join(' ')}: ${r.stderr}`);
+ return r.stdout;
+}
+
+function write(dir: string, rel: string, body: string, mode = 0o644): void {
+ fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true });
+ fs.writeFileSync(path.join(dir, rel), body, { mode });
+}
+
+function repair() {
+ return run('bash', ['-c', [
+ 'set -e',
+ 'QUIET=0',
+ 'log() { [ "$QUIET" -eq 0 ] && echo "$@" || true; }',
+ `SOURCE_GSTACK_DIR=${JSON.stringify(clone)}`,
+ `GSTACK_STATE_ROOT=${JSON.stringify(stateRoot)}`,
+ `. ${JSON.stringify(path.join(ROOT, 'bin', 'gstack-install-registry.sh'))}`,
+ extractFn('_repair_crlf_checkout'),
+ '_repair_crlf_checkout',
+ ].join('\n')], clone);
+}
+
+function runHook() {
+ return run('bash', [path.join(clone, HOOK_REL)], clone, JSON.stringify({ tool_name: 'Bash', tool_input: { command: 'ls' } }));
+}
+
+beforeAll(() => {
+ tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-crlf-'));
+ upstream = path.join(tmp, 'upstream');
+ clone = path.join(tmp, 'clone');
+ stateRoot = path.join(tmp, 'state');
+ const gitconfig = path.join(tmp, 'gitconfig');
+ fs.writeFileSync(gitconfig, '[user]\n\tname = t\n\temail = t@test\n[commit]\n\tgpgsign = false\n[init]\n\tdefaultBranch = main\n');
+ env = {
+ ...process.env,
+ HOME: tmp,
+ GIT_CONFIG_GLOBAL: gitconfig,
+ GIT_CONFIG_NOSYSTEM: '1',
+ PATH: `${path.dirname(process.execPath)}${path.delimiter}${process.env.PATH ?? ''}`,
+ };
+
+ fs.mkdirSync(upstream);
+ git(upstream, 'init', '-q');
+ write(upstream, '.gitattributes', '.gitattributes text eol=lf\n*.ts text eol=lf\n');
+ write(upstream, HOOK_REL, HOOK_SHIM, 0o755);
+ write(upstream, `${HOOK_REL}.ts`, "process.stdout.write('{}');\n");
+ write(upstream, EDITED_REL, '#!/usr/bin/env bash\nset -euo pipefail\necho upstream\n', 0o755);
+ git(upstream, 'add', '-A');
+ git(upstream, 'commit', '-qm', 'old release: no eol rule for nested bin/');
+
+ git(tmp, 'clone', '-q', '-c', 'core.autocrlf=true', upstream, clone);
+
+ write(upstream, '.gitattributes', '.gitattributes text eol=lf\n*.ts text eol=lf\n**/bin/* text eol=lf\n');
+ git(upstream, 'commit', '-qam', 'new release: nested bin/ is LF');
+ git(clone, 'pull', '-q', '--ff-only');
+
+ const edited = path.join(clone, EDITED_REL);
+ fs.writeFileSync(edited, fs.readFileSync(edited, 'utf-8') + 'echo local edit\r\n');
+});
+
+afterAll(() => {
+ fs.rmSync(tmp, { recursive: true, force: true });
+});
+
+describe('setup CRLF repair (#3110)', () => {
+ test('fixture precondition: the pulled rule left the CRLF copy, and Linux bash cannot run it', () => {
+ expect(fs.readFileSync(path.join(clone, HOOK_REL), 'utf-8')).toContain('\r\n');
+ expect(git(clone, 'check-attr', 'eol', '--', HOOK_REL)).toContain('eol: lf');
+ expect(git(clone, 'status', '--porcelain', '--untracked-files=no')).toBe(` M ${EDITED_REL}\n`);
+ const r = runHook();
+ expect(r.status).not.toBe(0);
+ expect(r.stderr).toContain('pipefail');
+ });
+
+ test('rewrites the unmodified file as LF, keeps the edited one, and the hook runs', () => {
+ const r = repair();
+ expect(r.status).toBe(0);
+ expect(r.stdout).toContain('Rewrote 1 file(s)');
+
+ const repaired = fs.readFileSync(path.join(clone, HOOK_REL));
+ expect(repaired.includes('\r')).toBe(false);
+ expect(repaired.toString('utf-8')).toBe(git(clone, 'show', `HEAD:${HOOK_REL}`));
+ expect(fs.statSync(path.join(clone, HOOK_REL)).mode & 0o111).not.toBe(0);
+ expect(git(clone, 'status', '--porcelain', '--untracked-files=no')).toBe(` M ${EDITED_REL}\n`);
+
+ const edited = fs.readFileSync(path.join(clone, EDITED_REL), 'utf-8');
+ expect(edited).toContain('echo local edit\r\n');
+ expect(edited).toContain('\r\n');
+ expect(r.stderr).toContain(EDITED_REL);
+ expect(r.stderr).toContain(`cd ${clone} && git stash && git stash pop`);
+ expect(r.stderr).toContain('docs/troubleshooting.md#setup-crlf-repair');
+ expect(fs.existsSync(path.join(stateRoot, 'installs.tsv.lock'))).toBe(false);
+
+ const hook = runHook();
+ expect(hook.stderr).toBe('');
+ expect(hook.status).toBe(0);
+ expect(hook.stdout.trim()).toBe('{}');
+ });
+
+ test('a second run rewrites nothing', () => {
+ const r = repair();
+ expect(r.status).toBe(0);
+ expect(r.stdout).not.toContain('Rewrote');
+ expect(r.stderr).toContain(EDITED_REL);
+ });
+
+ test('the printed fix rewrites the edited file as LF and keeps the edit', () => {
+ git(clone, 'stash', '-q');
+ git(clone, 'stash', 'pop', '-q');
+ const edited = fs.readFileSync(path.join(clone, EDITED_REL), 'utf-8');
+ expect(edited.includes('\r')).toBe(false);
+ expect(edited).toContain('echo local edit\n');
+ const r = repair();
+ expect(r.stdout + r.stderr).toBe('');
+ });
+});
diff --git a/test/timeline-stop-hook-slug.test.ts b/test/timeline-stop-hook-slug.test.ts
new file mode 100644
index 000000000..3b96bd95e
--- /dev/null
+++ b/test/timeline-stop-hook-slug.test.ts
@@ -0,0 +1,163 @@
+/**
+ * Timeline Stop hook slug resolution (#3103).
+ *
+ * Through Git Bash on Windows, gstack-slug took 2.2-2.8 s, past the hook's 2 s
+ * budget, so the synchronous spawn timed out on every Stop and nothing was
+ * repaired. The hook now reads gstack-slug's versioned cache in-process and,
+ * on a miss, hands the slow resolve to a detached child under a TTL lock with
+ * a per-cwd negative cache.
+ *
+ * Fixture: a copy of the hook and its imports in a temp tree whose
+ * bin/gstack-slug is a stub (runBin resolves bin/ from the hook's own
+ * location). The stub logs each call, sleeps, then prints a slug or fails.
+ * The hook runs through Bun argv, never as a shebang.
+ */
+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 { slugCacheFile } from '../lib/bin-context';
+
+const ROOT = path.resolve(import.meta.dir, '..');
+const HOOK_FILES = [
+ 'hosts/claude/hooks/timeline-stop-hook.ts',
+ 'hosts/claude/hooks/spawn-bin.ts',
+ 'hosts/claude/hooks/hook-log.ts',
+ 'lib/state-root.ts',
+ 'lib/bin-context.ts',
+ 'lib/remote-identity.ts',
+];
+const SLUG = 'stop-hook-slug-test';
+const BUDGET_MS = 2000;
+
+let tmp: string;
+let tree: string;
+let stateRoot: string;
+let projectDir: string;
+let callLog: string;
+
+function writeStub(body: string): void {
+ const stub = path.join(tree, 'bin', 'gstack-slug');
+ fs.mkdirSync(path.dirname(stub), { recursive: true });
+ fs.writeFileSync(stub, `#!/usr/bin/env bash\necho "$PWD" >> ${JSON.stringify(callLog)}\n${body}\n`, { mode: 0o755 });
+}
+
+function seedTimeline(): string {
+ const file = path.join(stateRoot, 'projects', SLUG, 'timeline.jsonl');
+ fs.mkdirSync(path.dirname(file), { recursive: true });
+ fs.writeFileSync(file, JSON.stringify({ skill: 'review', event: 'started', session: '11-1' }) + '\n');
+ return file;
+}
+
+function repairs(file: string): number {
+ return fs.readFileSync(file, 'utf-8').split('\n').filter((l) => l.includes('"source":"stop-hook"')).length;
+}
+
+function runHook(): { status: number | null; ms: number } {
+ const env: Record = { ...process.env, GSTACK_STATE_ROOT: stateRoot };
+ delete env.GSTACK_PROJECT_SLUG;
+ const t0 = Date.now();
+ const r = spawnSync(process.execPath, [path.join(tree, 'hosts', 'claude', 'hooks', 'timeline-stop-hook.ts')], {
+ input: JSON.stringify({ hook_event_name: 'Stop', cwd: projectDir }),
+ env,
+ timeout: 15_000,
+ });
+ return { status: r.status, ms: Date.now() - t0 };
+}
+
+function calls(): number {
+ return fs.existsSync(callLog) ? fs.readFileSync(callLog, 'utf-8').split('\n').filter(Boolean).length : 0;
+}
+
+const lockFile = () => path.join(stateRoot, 'timeline-stop-hook', 'repair.lock');
+
+async function until(cond: () => boolean, timeoutMs = 20_000): Promise {
+ const end = Date.now() + timeoutMs;
+ while (!cond()) {
+ if (Date.now() > end) throw new Error('condition not met before timeout');
+ await Bun.sleep(100);
+ }
+}
+
+beforeEach(() => {
+ tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-stop-slug-'));
+ tree = path.join(tmp, 'tree');
+ stateRoot = path.join(tmp, 'state');
+ projectDir = path.join(tmp, 'project');
+ callLog = path.join(tmp, 'slug-calls.log');
+ fs.mkdirSync(stateRoot);
+ fs.mkdirSync(projectDir);
+ for (const rel of HOOK_FILES) {
+ fs.mkdirSync(path.dirname(path.join(tree, rel)), { recursive: true });
+ fs.copyFileSync(path.join(ROOT, rel), path.join(tree, rel));
+ }
+});
+
+afterEach(async () => {
+ await until(() => !fs.existsSync(lockFile())).catch(() => {});
+ fs.rmSync(tmp, { recursive: true, force: true });
+});
+
+describe('timeline-stop-hook slug resolution (#3103)', () => {
+ test('cache hit: repairs in the foreground without spawning gstack-slug', () => {
+ writeStub('exit 1');
+ const timeline = seedTimeline();
+ const cache = slugCacheFile(stateRoot, projectDir);
+ fs.mkdirSync(path.dirname(cache), { recursive: true });
+ fs.writeFileSync(cache, `v2:${SLUG}`);
+
+ expect(runHook().status).toBe(0);
+ expect(repairs(timeline)).toBe(1);
+ expect(calls()).toBe(0);
+ expect(fs.existsSync(lockFile())).toBe(false);
+ });
+
+ test('cache miss with a slow gstack-slug: the hook returns within its budget and a background child repairs', async () => {
+ writeStub(`sleep 3\necho "SLUG=${SLUG}"\necho "BRANCH=main"`);
+ const timeline = seedTimeline();
+
+ const r = runHook();
+ expect(r.status).toBe(0);
+ expect(r.ms).toBeLessThan(BUDGET_MS);
+ expect(repairs(timeline)).toBe(0);
+
+ await until(() => repairs(timeline) === 1);
+ await until(() => !fs.existsSync(lockFile()));
+ expect(calls()).toBe(1);
+ expect(repairs(timeline)).toBe(1);
+ }, 30_000);
+
+ test('negative cache: a directory gstack-slug cannot resolve spawns one child, not one per turn', async () => {
+ writeStub('exit 1');
+ seedTimeline();
+
+ expect(runHook().status).toBe(0);
+ await until(() => calls() === 1 && !fs.existsSync(lockFile()));
+ expect(fs.readFileSync(path.join(stateRoot, 'hook-errors.log'), 'utf-8')).toContain('could not resolve project slug');
+
+ expect(runHook().status).toBe(0);
+ expect(runHook().status).toBe(0);
+ await Bun.sleep(1000);
+ expect(calls()).toBe(1);
+ }, 30_000);
+
+ test('a fresh lock defers the repair; a lock past its TTL is taken over', () => {
+ writeStub('exit 1');
+ const timeline = seedTimeline();
+ const cache = slugCacheFile(stateRoot, projectDir);
+ fs.mkdirSync(path.dirname(cache), { recursive: true });
+ fs.writeFileSync(cache, `v2:${SLUG}`);
+ fs.mkdirSync(path.dirname(lockFile()), { recursive: true });
+ fs.writeFileSync(lockFile(), '999999\n');
+
+ expect(runHook().status).toBe(0);
+ expect(repairs(timeline)).toBe(0);
+
+ const old = new Date(Date.now() - 120_000);
+ fs.utimesSync(lockFile(), old, old);
+ expect(runHook().status).toBe(0);
+ expect(repairs(timeline)).toBe(1);
+ expect(fs.existsSync(lockFile())).toBe(false);
+ });
+});