Merge origin/main (v1.67.0.0) — reconcile convergent iOS Release-guard fixes

main's v1.67.0.0 independently landed the DebugBridgeTouch Release compile-out
with a stronger shape (`#if !defined(DEBUG)` short-circuit before the platform
gate, measured via nm -j on a real Release binary) than this branch's
`#if TARGET_OS_IOS && DEBUG`. Resolution: take main's templates/fixtures, keep
this branch's free-tier static tripwire and adapt it to pin main's shape
(short-circuit present, ordered before the platform branch, cSettings DEBUG
define intact, no bare platform-only gate). VERSION/package.json stay 1.67.1.0;
CHANGELOG keeps both entries with 1.67.1.0 on top, its iOS claims reworded to
the residual contribution (the tripwire, not the compile-out itself).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-08-16 20:18:43 -07:00
co-authored by Claude Fable 5
246 changed files with 13732 additions and 2289 deletions
@@ -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/<slug>/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 ----/);
});
});
-58
View File
@@ -1,58 +0,0 @@
import { describe, test, expect } from 'bun:test';
import { readFileSync } from 'fs';
import { join } from 'path';
const ROOT = join(import.meta.dir, '..');
// Security regression guard for the basic-ftp transitive dependency.
//
// basic-ftp reaches the tree only transitively:
// puppeteer-core > @puppeteer/browsers > proxy-agent > pac-proxy-agent > get-uri > basic-ftp
//
// Versions <= 5.3.0 carry four HIGH advisories, all fixed in 5.3.1:
// - GHSA-chqc-8p9q-pq6q CVE-2026-39983 FTP command injection via CRLF (fixed 5.2.1)
// - GHSA-6v7q-wjvx-w8wg incomplete CRLF protection, USER/PASS + MKD bypass (fixed 5.2.2)
// - GHSA-rpmf-866q-6p89 DoS via unbounded multiline control-response buffering
// - GHSA-rp42-5vxx-qpwr DoS via unbounded memory in Client.list()
//
// The fix is a bun `overrides` pin (not a phantom direct dependency): overriding
// forces EVERY basic-ftp in the tree to the safe version, including get-uri's
// nested copy. A direct-dependency bump leaves that nested copy behind — which is
// exactly the failure mode this test is here to catch. See the CVE-2026-39983
// upgrade fix for the full rationale.
const MIN_SAFE = '5.3.1';
function cmpSemver(a: string, b: string): number {
const pa = a.split('.').map(Number);
const pb = b.split('.').map(Number);
for (let i = 0; i < 3; i++) {
if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) - (pb[i] ?? 0);
}
return 0;
}
describe('basic-ftp security pin (CVE-2026-39983 and siblings)', () => {
test('package.json overrides basic-ftp to a safe version', () => {
const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf-8'));
const pin = pkg.overrides?.['basic-ftp'];
expect(pin, 'package.json overrides.basic-ftp must exist').toBeDefined();
const pinned = String(pin).replace(/^[\^~]/, '');
expect(
cmpSemver(pinned, MIN_SAFE) >= 0,
`overrides.basic-ftp is "${pin}" but must be >= ${MIN_SAFE}`,
).toBe(true);
});
test('no basic-ftp entry in bun.lock resolves below the safe version', () => {
const lock = readFileSync(join(ROOT, 'bun.lock'), 'utf-8');
// Match every resolved basic-ftp specifier, including nested paths like
// "get-uri/basic-ftp" that a direct-dependency bump would leave vulnerable.
const versions = [...lock.matchAll(/basic-ftp@(\d+\.\d+\.\d+)/g)].map(m => m[1]);
expect(versions.length, 'expected at least one basic-ftp entry in bun.lock').toBeGreaterThan(0);
const vulnerable = versions.filter(v => cmpSemver(v, MIN_SAFE) < 0);
expect(
vulnerable,
`bun.lock still resolves vulnerable basic-ftp version(s): ${vulnerable.join(', ')}`,
).toEqual([]);
});
});
+113
View File
@@ -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
}
});
});
+59 -1
View File
@@ -131,6 +131,59 @@ describe('brain-cache endpoint detection', () => {
expect(typeof hash).toBe('string');
expect(hash.length).toBeGreaterThan(0);
});
// #2499: project-scoped registrations (.projects["/path"].mcpServers.gbrain)
// were never read — two different project-scoped brains both hashed to
// 'local', so switching between them never invalidated the cache.
test('detectEndpointHash resolves a project-scoped gbrain URL for a cwd inside the project (#2499)', async () => {
const mod = await importCache();
const cj = join(TMP_HOME, 'claude.json');
writeFileSync(cj, JSON.stringify({
projects: {
'/w/repo': { mcpServers: { gbrain: { type: 'http', url: 'https://a.example/mcp' } } },
},
}));
const inside = mod.detectEndpointHash(cj, '/w/repo/src/deep');
expect(inside).not.toBe('local');
expect(inside).toHaveLength(8);
// Path-boundary check: /w/repo2 is NOT inside /w/repo.
expect(mod.detectEndpointHash(cj, '/w/repo2')).toBe('local');
});
test('detectEndpointHash prefers the nearest-ancestor project entry (#2499)', async () => {
const mod = await importCache();
const cj = join(TMP_HOME, 'claude.json');
writeFileSync(cj, JSON.stringify({
projects: {
'/w/repo': { mcpServers: { gbrain: { url: 'https://outer.example/mcp' } } },
'/w/repo/nested': { mcpServers: { gbrain: { url: 'https://inner.example/mcp' } } },
},
}));
const inner = mod.detectEndpointHash(cj, '/w/repo/nested/sub');
const outer = mod.detectEndpointHash(cj, '/w/repo/other');
expect(inner).not.toBe(outer); // two brains → two hashes (the docstring scenario)
expect(inner).not.toBe('local');
expect(outer).not.toBe('local');
});
test('detectEndpointHash still prefers user scope over project scope (#2499)', async () => {
const mod = await importCache();
const cj = join(TMP_HOME, 'claude.json');
writeFileSync(cj, JSON.stringify({
mcpServers: { gbrain: { url: 'https://user.example/mcp' } },
projects: {
'/w/repo': { mcpServers: { gbrain: { url: 'https://proj.example/mcp' } } },
},
}));
const userScoped = mod.detectEndpointHash(cj, '/w/repo');
// Same file minus the user-scope entry → different hash proves user scope won.
writeFileSync(cj, JSON.stringify({
projects: {
'/w/repo': { mcpServers: { gbrain: { url: 'https://proj.example/mcp' } } },
},
}));
expect(mod.detectEndpointHash(cj, '/w/repo')).not.toBe(userScoped);
});
});
describe('brain-cache schema mismatch behavior', () => {
@@ -153,7 +206,12 @@ describe('brain-cache schema mismatch behavior', () => {
// the file gets deleted by the rebuild step. State should be 'missing' or
// 'stale-fallback' depending on whether the rebuild left a file behind.
expect(['missing', 'cold-refreshed', 'stale-fallback']).toContain(result.state);
});
}, 30000);
// ^ 30s: the schema-mismatch rebuild refreshes EVERY per-project entity,
// each spawning the real gbrain CLI (no mock here). With an unreachable
// brain each spawn runs to its own timeout, and under machine load the
// stack exceeds bun's 5s default — observed at 5.2-5.4s on a loaded box,
// identically on pre-fix binaries (load flake, not a code regression).
});
describe('brain-cache state machine', () => {
+232
View File
@@ -448,3 +448,235 @@ describe('gstack-brain-sync --discover-new', () => {
expect(queue.trim()).toBe('');
});
});
// ---------------------------------------------------------------
// #2549 queue integrity: classified drops, privacy retention,
// surgical rewrite, unpushed-commit detector
// ---------------------------------------------------------------
describe('#2549 queue integrity', () => {
function initWithMode(mode: string) {
run(['gstack-artifacts-init', '--remote', bareRemote]);
run(['gstack-config', 'set', 'artifacts_sync_mode', mode]);
}
const queueText = () => fs.readFileSync(path.join(tmpHome, '.brain-queue.jsonl'), 'utf-8');
const statusJson = () => JSON.parse(fs.readFileSync(path.join(tmpHome, '.brain-sync-status.json'), 'utf-8'));
test('privacy-held entries are RETAINED and classified, not wiped as "no allowlisted changes"', () => {
// timeline.jsonl is class=behavioral; artifacts-only mode holds it.
initWithMode('artifacts-only');
fs.mkdirSync(path.join(tmpHome, 'projects', 'p'), { recursive: true });
fs.writeFileSync(path.join(tmpHome, 'projects/p/timeline.jsonl'), '{"skill":"x","event":"started"}\n');
run(['gstack-brain-enqueue', 'projects/p/timeline.jsonl']);
const r = run(['gstack-brain-sync', '--once']);
expect(r.status).toBe(0);
// The exact #2549 repro: the old code truncated the queue here and said
// "no allowlisted changes in queue". The entry must survive, and the
// status must attribute the hold honestly.
expect(queueText()).toContain('projects/p/timeline.jsonl');
const s = statusJson();
expect(s.status).toBe('idle');
expect(s.message).toContain('privacy-held retained');
expect(s.message).not.toContain('no allowlisted changes');
});
test('unmatched and missing entries drop WITH counts and a 0600 drops sidecar', () => {
initWithMode('full');
fs.mkdirSync(path.join(tmpHome, 'projects', 'p'), { recursive: true });
// Unmatched: no allowlist glob covers .txt scratch files.
fs.writeFileSync(path.join(tmpHome, 'projects/p/scratch.txt'), 'x\n');
fs.appendFileSync(path.join(tmpHome, '.brain-queue.jsonl'), '{"file":"projects/p/scratch.txt"}\n');
// Missing: allowlisted name that does not exist on disk.
fs.appendFileSync(path.join(tmpHome, '.brain-queue.jsonl'), '{"file":"projects/p/learnings.jsonl"}\n');
const r = run(['gstack-brain-sync', '--once']);
expect(r.status).toBe(0);
expect(queueText()).not.toContain('scratch.txt');
expect(queueText()).not.toContain('learnings.jsonl');
const s = statusJson();
expect(s.message).toContain('1 unmatched dropped');
expect(s.message).toContain('1 missing dropped');
const drops = path.join(tmpHome, '.brain-sync-drops.json');
expect(fs.existsSync(drops)).toBe(true);
if (process.platform !== 'win32') {
expect(fs.statSync(drops).mode & 0o777).toBe(0o600);
}
const detail = JSON.parse(fs.readFileSync(drops, 'utf-8'));
expect(detail.dropped.unmatched).toContain('projects/p/scratch.txt');
expect(detail.dropped.missing).toContain('projects/p/learnings.jsonl');
});
test('an unparseable queue line is preserved, never destroyed', () => {
initWithMode('full');
fs.appendFileSync(path.join(tmpHome, '.brain-queue.jsonl'), 'not json at all\n');
const r = run(['gstack-brain-sync', '--once']);
expect(r.status).toBe(0);
expect(queueText()).toContain('not json at all');
});
test('surgical rewrite: a synced entry leaves the queue while a held sibling survives the same drain', () => {
// Proves the rewrite is a live filtered rewrite, not a truncation: two
// entries drain in one --once, one stages+pushes, one is mode-held.
initWithMode('artifacts-only');
fs.mkdirSync(path.join(tmpHome, 'projects', 'p'), { recursive: true });
fs.writeFileSync(path.join(tmpHome, 'projects/p/learnings.jsonl'), '{"skill":"x","insight":"y","ts":"2026-01-01T00:00:00Z"}\n');
fs.writeFileSync(path.join(tmpHome, 'projects/p/timeline.jsonl'), '{"skill":"x","event":"started"}\n');
run(['gstack-brain-enqueue', 'projects/p/learnings.jsonl']);
run(['gstack-brain-enqueue', 'projects/p/timeline.jsonl']);
const r = run(['gstack-brain-sync', '--once']);
expect(r.status).toBe(0);
expect(queueText()).not.toContain('learnings.jsonl'); // synced, removed
expect(queueText()).toContain('timeline.jsonl'); // held, retained
const log = spawnSync('git', ['--git-dir=' + bareRemote, 'log', '--oneline'], { encoding: 'utf-8' });
expect(log.stdout).toMatch(/sync: 1 file/);
});
test('push failure retains the commit locally and the run-start detector re-pushes it', () => {
initWithMode('full');
// Establish origin/main so the detector has a remote ref to compare.
fs.mkdirSync(path.join(tmpHome, 'projects', 'p'), { recursive: true });
fs.writeFileSync(path.join(tmpHome, 'projects/p/learnings.jsonl'), '{"skill":"a","ts":"2026-01-01T00:00:00Z"}\n');
run(['gstack-brain-enqueue', 'projects/p/learnings.jsonl']);
expect(run(['gstack-brain-sync', '--once']).status).toBe(0);
// Reject the next push at the remote (pre-receive hook exits 1 with an
// auth-shaped message so the auth branch is exercised too).
const hook = path.join(bareRemote, 'hooks', 'pre-receive');
fs.writeFileSync(hook, '#!/bin/sh\necho "403 forbidden" >&2\nexit 1\n');
fs.chmodSync(hook, 0o755);
fs.appendFileSync(path.join(tmpHome, 'projects/p/learnings.jsonl'), '{"skill":"b","ts":"2026-01-02T00:00:00Z"}\n');
run(['gstack-brain-enqueue', 'projects/p/learnings.jsonl']);
const fail = run(['gstack-brain-sync', '--once']);
expect(fail.status).toBe(0);
const s = statusJson();
expect(s.status).toBe('push_failed');
expect(s.message).toContain('commit retained locally');
// Drained path left the queue — it lives in the local commit now.
expect(queueText()).not.toContain('learnings.jsonl');
// The commit exists locally, ahead of origin.
const ahead = git(['rev-list', '--count', 'origin/main..HEAD']).stdout.trim();
expect(Number(ahead)).toBeGreaterThan(0);
// Remote healthy again: an EMPTY-queue run must still deliver the
// stranded commit (the detector, not the drain, pushes it).
fs.rmSync(hook);
const retry = run(['gstack-brain-sync', '--once']);
expect(retry.status).toBe(0);
const log = spawnSync('git', ['--git-dir=' + bareRemote, 'log', '--oneline'], { encoding: 'utf-8' });
expect(log.stdout).toMatch(/sync: 1 file/);
expect(git(['rev-list', '--count', 'origin/main..HEAD']).stdout.trim()).toBe('0');
});
test('receipt refusal at the detector skips the retry without wedging the drain', () => {
if (process.platform === 'win32' || process.getuid?.() === 0) return; // chmod advisory there
initWithMode('full');
fs.mkdirSync(path.join(tmpHome, 'projects', 'p'), { recursive: true });
fs.writeFileSync(path.join(tmpHome, 'projects/p/learnings.jsonl'), '{"skill":"a","ts":"2026-01-01T00:00:00Z"}\n');
run(['gstack-brain-enqueue', 'projects/p/learnings.jsonl']);
expect(run(['gstack-brain-sync', '--once']).status).toBe(0);
// Strand a commit: reject pushes, drain once.
const hook = path.join(bareRemote, 'hooks', 'pre-receive');
fs.writeFileSync(hook, '#!/bin/sh\nexit 1\n');
fs.chmodSync(hook, 0o755);
fs.appendFileSync(path.join(tmpHome, 'projects/p/learnings.jsonl'), '{"skill":"b","ts":"2026-01-02T00:00:00Z"}\n');
run(['gstack-brain-enqueue', 'projects/p/learnings.jsonl']);
expect(run(['gstack-brain-sync', '--once']).status).toBe(0);
fs.rmSync(hook);
// Break receipts. The detector's retry must be SKIPPED (no wedge), and
// the run must still exit 0 with nothing else to do.
fs.mkdirSync(path.join(tmpHome, 'security'), { recursive: true });
fs.chmodSync(path.join(tmpHome, 'security'), 0o500);
try {
const r = run(['gstack-brain-sync', '--once']);
expect(r.status).toBe(0);
// Commit still stranded (retry skipped, not attempted unreceipted).
expect(Number(git(['rev-list', '--count', 'origin/main..HEAD']).stdout.trim())).toBeGreaterThan(0);
} finally {
fs.chmodSync(path.join(tmpHome, 'security'), 0o700);
}
// Receipts healthy: detector delivers. The refused attempt above stamped
// the 10-minute throttle (deliberately — refusals must not busy-loop the
// network at every skill boundary), so model the interval passing.
fs.writeFileSync(path.join(tmpHome, '.brain-last-push-attempt'), '0');
expect(run(['gstack-brain-sync', '--once']).status).toBe(0);
expect(git(['rev-list', '--count', 'origin/main..HEAD']).stdout.trim()).toBe('0');
});
test('detector attempts are throttled to one per interval', () => {
initWithMode('full');
fs.mkdirSync(path.join(tmpHome, 'projects', 'p'), { recursive: true });
fs.writeFileSync(path.join(tmpHome, 'projects/p/learnings.jsonl'), '{"skill":"a","ts":"2026-01-01T00:00:00Z"}\n');
run(['gstack-brain-enqueue', 'projects/p/learnings.jsonl']);
expect(run(['gstack-brain-sync', '--once']).status).toBe(0);
// Strand a commit behind a rejecting remote.
const hook = path.join(bareRemote, 'hooks', 'pre-receive');
fs.writeFileSync(hook, '#!/bin/sh\nexit 1\n');
fs.chmodSync(hook, 0o755);
fs.appendFileSync(path.join(tmpHome, 'projects/p/learnings.jsonl'), '{"skill":"b","ts":"2026-01-02T00:00:00Z"}\n');
run(['gstack-brain-enqueue', 'projects/p/learnings.jsonl']);
expect(run(['gstack-brain-sync', '--once']).status).toBe(0);
fs.rmSync(hook);
// First empty-queue run: detector attempts (stamps the throttle), pushes.
expect(run(['gstack-brain-sync', '--once']).status).toBe(0);
const stamp1 = fs.readFileSync(path.join(tmpHome, '.brain-last-push-attempt'), 'utf-8');
expect(Number(stamp1)).toBeGreaterThan(0);
expect(git(['rev-list', '--count', 'origin/main..HEAD']).stdout.trim()).toBe('0');
// Strand another; an immediate second run must NOT attempt (stamp fresh).
fs.writeFileSync(hook, '#!/bin/sh\nexit 1\n');
fs.chmodSync(hook, 0o755);
fs.appendFileSync(path.join(tmpHome, 'projects/p/learnings.jsonl'), '{"skill":"c","ts":"2026-01-03T00:00:00Z"}\n');
run(['gstack-brain-enqueue', 'projects/p/learnings.jsonl']);
expect(run(['gstack-brain-sync', '--once']).status).toBe(0);
fs.rmSync(hook);
const stampBefore = fs.readFileSync(path.join(tmpHome, '.brain-last-push-attempt'), 'utf-8');
expect(run(['gstack-brain-sync', '--once']).status).toBe(0);
// Throttled: stamp unchanged, commit still stranded.
expect(fs.readFileSync(path.join(tmpHome, '.brain-last-push-attempt'), 'utf-8')).toBe(stampBefore);
expect(Number(git(['rev-list', '--count', 'origin/main..HEAD']).stdout.trim())).toBeGreaterThan(0);
// Interval passed: delivers.
fs.writeFileSync(path.join(tmpHome, '.brain-last-push-attempt'), '0');
expect(run(['gstack-brain-sync', '--once']).status).toBe(0);
expect(git(['rev-list', '--count', 'origin/main..HEAD']).stdout.trim()).toBe('0');
});
test('an interleaved user commit disables the detector push (exclusive author gate)', () => {
initWithMode('full');
fs.mkdirSync(path.join(tmpHome, 'projects', 'p'), { recursive: true });
fs.writeFileSync(path.join(tmpHome, 'projects/p/learnings.jsonl'), '{"skill":"a","ts":"2026-01-01T00:00:00Z"}\n');
run(['gstack-brain-enqueue', 'projects/p/learnings.jsonl']);
expect(run(['gstack-brain-sync', '--once']).status).toBe(0);
// Strand a bot commit behind a rejecting remote.
const hook = path.join(bareRemote, 'hooks', 'pre-receive');
fs.writeFileSync(hook, '#!/bin/sh\nexit 1\n');
fs.chmodSync(hook, 0o755);
fs.appendFileSync(path.join(tmpHome, 'projects/p/learnings.jsonl'), '{"skill":"b","ts":"2026-01-02T00:00:00Z"}\n');
run(['gstack-brain-enqueue', 'projects/p/learnings.jsonl']);
expect(run(['gstack-brain-sync', '--once']).status).toBe(0);
fs.rmSync(hook);
// A user manually commits in ~/.gstack on top of the stranded bot commit.
expect(git(['-c', 'user.name=Garry', '-c', 'user.email=garry@example.com',
'-c', 'commit.gpgsign=false',
'commit', '--allow-empty', '-m', 'manual note']).status).toBe(0);
// Interval passed, remote healthy, queue empty: the detector must STILL
// refuse — `push origin HEAD` would publish the user's commit uninvited.
fs.writeFileSync(path.join(tmpHome, '.brain-last-push-attempt'), '0');
expect(run(['gstack-brain-sync', '--once']).status).toBe(0);
expect(Number(git(['rev-list', '--count', 'origin/main..HEAD']).stdout.trim())).toBe(2);
// A REAL drain still rides the user commit along, as before — the gate
// scopes only the detector's autonomous retry, not user-initiated syncs.
fs.appendFileSync(path.join(tmpHome, 'projects/p/learnings.jsonl'), '{"skill":"c","ts":"2026-01-03T00:00:00Z"}\n');
run(['gstack-brain-enqueue', 'projects/p/learnings.jsonl']);
expect(run(['gstack-brain-sync', '--once']).status).toBe(0);
expect(git(['rev-list', '--count', 'origin/main..HEAD']).stdout.trim()).toBe('0');
});
});
+134
View File
@@ -0,0 +1,134 @@
/**
* Branch-name slug hygiene in file-path positions (#2550, #1851/#1127).
*
* gstack-review-log WRITES `<canonical-branch>-reviews.jsonl` where the
* canonical form comes from bin/gstack-slug (tr '/' '-' then
* tr -cd 'a-zA-Z0-9._-'). Context Recovery used to PROBE the same file with
* raw $_BRANCH (`git branch --show-current`) — so for any branch containing
* a `/` (most feature branches) the REVIEWS line never fired. Same class:
* review.ts's plan content-search sanitized with tr '/' '-' only, missing
* the tr -cd half of the canonical pipeline.
*
* Discipline pinned here:
* - FILE-PATH positions interpolate the slug-canonical $BRANCH (set by the
* gstack-slug eval that opens Context Recovery).
* - Raw $_BRANCH stays for display (BRANCH: echo) and for timeline.jsonl
* content greps — the timeline writer stores the RAW branch, so slugging
* the reader would break that pairing.
*
* Reader-side fix folded from community PR #1851 by @harjothkhara.
*/
import { describe, test, expect } from 'bun:test';
import { execSync } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { HOST_PATHS } from '../scripts/resolvers/types';
import type { TemplateContext } from '../scripts/resolvers/types';
import { generateContextRecovery } from '../scripts/resolvers/preamble/generate-context-recovery';
const ROOT = path.join(import.meta.dir, '..');
// Raw $_BRANCH (either spelling) immediately before/after a path separator.
const PATH_ADJACENT = /\/\$\{?_BRANCH|\$\{_BRANCH\}\/|\$_BRANCH\//;
// Raw $_BRANCH as a filename prefix (…-reviews.jsonl and friends).
const FILENAME_PREFIX = /\$\{?_BRANCH\}?[A-Za-z0-9._-]*\.(?:jsonl|json|md|txt|log)/;
function renderedSkillFiles(): string[] {
const out = execSync(
`find "${ROOT}" -name 'SKILL.md' -not -path '*/node_modules/*' -not -path '*/.claude/*' ; find "${ROOT}" -path '*/sections/*.md' -not -path '*/node_modules/*' -not -path '*/.claude/*'`,
{ encoding: 'utf-8' },
);
return out.split('\n').filter(Boolean);
}
describe('branch slug hygiene (#2550, #1851)', () => {
test('no generated SKILL.md or section interpolates raw $_BRANCH in a path position', () => {
const offenders: string[] = [];
for (const file of renderedSkillFiles()) {
const content = fs.readFileSync(file, 'utf-8');
if (PATH_ADJACENT.test(content) || FILENAME_PREFIX.test(content)) {
offenders.push(path.relative(ROOT, file));
}
}
expect(offenders).toEqual([]);
});
test('Context Recovery probes reviews.jsonl with the slug-canonical $BRANCH', () => {
const ctx: TemplateContext = {
skillName: 'test-skill',
tmplPath: 'test.tmpl',
host: 'claude',
paths: HOST_PATHS.claude,
preambleTier: 2,
};
const out = generateContextRecovery(ctx);
expect(out).toContain('${BRANCH:-unknown}-reviews.jsonl');
expect(out).not.toContain('${_BRANCH}-reviews.jsonl');
// The gstack-slug eval that defines $BRANCH must render BEFORE the probe.
const evalIdx = out.indexOf('gstack-slug');
const probeIdx = out.indexOf('${BRANCH:-unknown}-reviews.jsonl');
expect(evalIdx).toBeGreaterThan(-1);
expect(evalIdx).toBeLessThan(probeIdx);
// Raw $_BRANCH stays for the timeline.jsonl content greps (writer stores raw).
expect(out).toContain('"branch\\":\\"${_BRANCH}');
});
test('plan content-search BRANCH uses the full gstack-slug canonical pipeline', () => {
const rendered = fs.readFileSync(
path.join(ROOT, 'ship', 'sections', 'plan-completion.md'),
'utf-8',
);
expect(rendered).toContain(
`BRANCH=$(git branch --show-current 2>/dev/null | tr '/' '-' | tr -cd 'a-zA-Z0-9._-')`,
);
});
test('live round-trip: gstack-review-log writes, Context Recovery probe finds it (slash branch)', () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-home-'));
const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-repo-'));
try {
const env = { ...process.env, GSTACK_HOME: home };
execSync(
'git init -q && git -c user.email=t@t -c user.name=t commit -q --allow-empty -m init && git checkout -q -b feat/slug-hygiene',
{ cwd: repo, encoding: 'utf-8' },
);
// Writer: the real gstack-review-log (canonicalizes via gstack-slug).
execSync(
`"${path.join(ROOT, 'bin', 'gstack-review-log')}" '{"skill":"ship","status":"ok"}'`,
{ cwd: repo, env, encoding: 'utf-8' },
);
// The slug-canonical filename must exist; the raw form must not.
const slugVars = execSync(`"${path.join(ROOT, 'bin', 'gstack-slug')}"`, {
cwd: repo, env, encoding: 'utf-8',
});
const slug = slugVars.match(/^SLUG=(.*)$/m)![1];
const branch = slugVars.match(/^BRANCH=(.*)$/m)![1];
expect(branch).toBe('feat-slug-hygiene');
const proj = path.join(home, 'projects', slug);
expect(fs.existsSync(path.join(proj, 'feat-slug-hygiene-reviews.jsonl'))).toBe(true);
// Reader: execute the rendered probe line with $BRANCH from gstack-slug.
const ctx: TemplateContext = {
skillName: 'test-skill', tmplPath: 'test.tmpl', host: 'claude',
paths: HOST_PATHS.claude, preambleTier: 2,
};
const probeLine = generateContextRecovery(ctx)
.split('\n')
.find((l) => l.includes('-reviews.jsonl'))!;
const script = `_PROJ="${proj}"\nBRANCH="${branch}"\n${probeLine.trim()}`;
const out = execSync(`bash -c '${script.replace(/'/g, `'\\''`)}'`, {
cwd: repo, encoding: 'utf-8',
});
expect(out).toContain('REVIEWS: 1 entries');
// Negative control: the raw-branch probe (the pre-fix shape) misses.
expect(fs.existsSync(path.join(proj, 'feat/slug-hygiene-reviews.jsonl'))).toBe(false);
} finally {
fs.rmSync(home, { recursive: true, force: true });
fs.rmSync(repo, { recursive: true, force: true });
}
});
});
+21 -4
View File
@@ -77,17 +77,34 @@ describe("buildGbrainEnv", () => {
expect(result.DATABASE_URL).toBe("postgresql://app/db");
});
it("honors GBRAIN_HOME when set (config aligned with detectEngineTier)", () => {
// Move the config to an alternate dir; set GBRAIN_HOME to point at it.
it("honors GBRAIN_HOME when set, with gbrain's parent-dir semantics (#2521)", () => {
// Move the config to an alternate dir; set GBRAIN_HOME to point at its
// PARENT — gbrain's configDir() appends `.gbrain` itself, so
// GBRAIN_HOME=/x reads /x/.gbrain/config.json.
const altGbrainHome = join(home, "alt-gbrain");
mkdirSync(altGbrainHome, { recursive: true });
writeFileSync(join(altGbrainHome, "config.json"), JSON.stringify({ database_url: "postgresql://alt/db" }));
mkdirSync(join(altGbrainHome, ".gbrain"), { recursive: true });
writeFileSync(
join(altGbrainHome, ".gbrain", "config.json"),
JSON.stringify({ database_url: "postgresql://alt/db" }),
);
// No file at the default ~/.gbrain location.
const baseEnv = { HOME: home, GBRAIN_HOME: altGbrainHome };
const result = buildGbrainEnv({ baseEnv });
expect(result.DATABASE_URL).toBe("postgresql://alt/db");
});
it("ignores a config at $GBRAIN_HOME/config.json — gbrain never reads that file (#2521)", () => {
const altGbrainHome = join(home, "alt-gbrain-flat");
mkdirSync(altGbrainHome, { recursive: true });
writeFileSync(
join(altGbrainHome, "config.json"),
JSON.stringify({ database_url: "postgresql://alt/db" }),
);
const baseEnv = { HOME: home, GBRAIN_HOME: altGbrainHome };
const result = buildGbrainEnv({ baseEnv });
expect(result.DATABASE_URL).toBeUndefined();
});
it("returns a fresh env object — never the caller's env by identity", () => {
// Codex review #11: object-identity equality lets later mutation of the
// returned env leak back into the caller's view. The helper MUST clone.
+29
View File
@@ -23,8 +23,37 @@ import {
buildTrimmedDescription,
buildWhenToInvokeSection,
applyCatalogTrim,
toYamlInlineScalar,
} from '../scripts/gen-skill-docs';
describe('toYamlInlineScalar', () => {
const parses = (out: string) => Bun.YAML.parse(`d: ${out}`);
test("scalar containing '...' (YAML document-end marker) is quoted and round-trips", () => {
const out = toYamlInlineScalar('Truncated lead ends with... more (gstack)');
expect(out.startsWith('"')).toBe(true);
expect((parses(out) as { d: string }).d).toContain('...');
});
test("interior ': ' is quoted (nested-mapping ambiguity, #1778)", () => {
const out = toYamlInlineScalar('Ship workflow: detect and merge');
expect(out.startsWith('"')).toBe(true);
expect((parses(out) as { d: string }).d).toBe('Ship workflow: detect and merge');
});
test('plain safe scalar passes through unquoted', () => {
expect(toYamlInlineScalar('Simple description here')).toBe('Simple description here');
});
test('leading YAML indicator char is quoted', () => {
expect(toYamlInlineScalar('- leading dash').startsWith('"')).toBe(true);
});
test('trailing whitespace is quoted', () => {
expect(toYamlInlineScalar('has trailing space ').startsWith('"')).toBe(true);
});
});
describe('splitCatalogDescription', () => {
test('extracts lead sentence + routing prose from simple multi-line description', () => {
const desc =
+3 -1
View File
@@ -953,7 +953,9 @@ exit 1
...process.env,
GSTACK_HOME: home,
HOME: home,
GBRAIN_HOME: gbrainHome,
// #2521: GBRAIN_HOME is the PARENT of .gbrain per gbrain's configDir()
// contract — pointing it at `home` resolves to home/.gbrain/config.json.
GBRAIN_HOME: home,
PATH: `${shimDir}:${process.env.PATH}`,
// Dead loopback port → the Sourcebot probe fails fast + deterministically
// (connection refused) instead of poking whatever operator dev server
+23
View File
@@ -287,6 +287,29 @@ describe('gstack-codex-probe: timeout wrapper + namespace hygiene', () => {
}
});
test('bash-native watchdog kills a hung command at the deadline (exit 124, no timeout binary)', () => {
// Stock macOS ships neither gtimeout nor timeout(1) — the old fallback ran
// the command unwrapped, so a hung `codex exec` blocked the calling
// workflow forever. Force the fallback everywhere (Linux /bin has timeout
// via usrmerge) with a PATH holding ONLY bash and sleep, then prove a
// 30s sleep dies at the 1s deadline with timeout(1)'s exit code. The
// runProbe 5s spawnSync cap doubles as the "actually killed fast" bound.
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-watchdog-'));
try {
const which = (tool: string) =>
spawnSync('bash', ['-c', `command -v ${tool}`]).stdout.toString().trim() || `/bin/${tool}`;
fs.symlinkSync(which('bash'), path.join(dir, 'bash'));
fs.symlinkSync(which('sleep'), path.join(dir, 'sleep'));
const r = runProbe({
snippet: `_gstack_codex_timeout_wrapper 1 sleep 30; echo "rc=$?"`,
env: { PATH: dir },
});
expect(r.stdout).toContain('rc=124');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('sourcing probe does NOT set errexit/trap/IFS in caller shell (namespace hygiene)', () => {
// Capture `set -o` output before and after sourcing. Any drift means the
// probe polluted the caller.
+219
View File
@@ -0,0 +1,219 @@
/**
* _gstack_codex_model_probe — round-trip model readiness (#2477).
*
* The auth probe accepts "auth exists" as readiness, but a ChatGPT account
* with a stale `model = "..."` pin in ~/.codex/config.toml passes auth and
* then dies with an HTTP 400 on every invocation. The model probe does one
* short `codex exec "reply OK"` round trip with the configured model.
*
* Contract pinned here (all runs use a STUBBED codex binary):
* - exit 0 -> MODEL_OK, result cached (1h TTL + config/auth
* mtime signature), second call does NOT re-invoke
* - model 400 output -> MODEL_UNUSABLE (exit 1) + config.toml HINT lines,
* negative-cached 15 min (same exit-1 + hints from
* cache; re-probing every preflight charged the
* affected user 30s + real tokens per section)
* - transient failure -> MODEL_PROBE_INCONCLUSIVE, FAIL-OPEN (exit 0),
* never cached
* - config.toml mtime change invalidates a cached MODEL_OK and a cached
* MODEL_UNUSABLE (editing the pin IS the fix)
*/
import { describe, test, expect } 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 PROBE = path.join(ROOT, 'bin', 'gstack-codex-probe');
const STUB = `#!/usr/bin/env bash
echo "invoked" >> "$STUB_LOG"
case "\${STUB_MODE:-ok}" in
ok) echo "OK"; exit 0 ;;
model400)
echo 'warning: Model metadata for \`gpt-5.4\` not found.' >&2
echo 'ERROR: {"type":"error","status":400,"error":{"type":"invalid_request_error","message":"The '"'"'gpt-5.4'"'"' model is not supported when using Codex with a ChatGPT account."}}' >&2
exit 1 ;;
transient) echo "stream error: network unreachable" >&2; exit 7 ;;
esac
`;
interface Fixture {
home: string;
stubDir: string;
codexHome: string;
gstackHome: string;
stubLog: string;
}
function makeFixture(): Fixture {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-model-probe-'));
const stubDir = path.join(home, 'stub-bin');
const codexHome = path.join(home, '.codex');
const gstackHome = path.join(home, '.gstack');
fs.mkdirSync(stubDir, { recursive: true });
fs.mkdirSync(codexHome, { recursive: true });
fs.mkdirSync(gstackHome, { recursive: true });
fs.writeFileSync(path.join(stubDir, 'codex'), STUB, { mode: 0o755 });
fs.writeFileSync(path.join(codexHome, 'config.toml'), 'model = "gpt-5.4"\n');
fs.writeFileSync(path.join(codexHome, 'auth.json'), '{}');
const stubLog = path.join(home, 'stub.log');
return { home, stubDir, codexHome, gstackHome, stubLog };
}
function runProbe(f: Fixture, stubMode: string): { stdout: string; status: number } {
const result = spawnSync(
'bash',
['-c', `set +e\nsource "${PROBE}"\n_gstack_codex_model_probe`],
{
env: {
PATH: `${f.stubDir}:${process.env.PATH ?? ''}`,
HOME: f.home,
CODEX_HOME: f.codexHome,
GSTACK_HOME: f.gstackHome,
STUB_MODE: stubMode,
STUB_LOG: f.stubLog,
_TEL: 'off',
},
timeout: 10000,
},
);
return { stdout: (result.stdout ?? '').toString(), status: result.status ?? -1 };
}
function invocations(f: Fixture): number {
try {
return fs.readFileSync(f.stubLog, 'utf-8').split('\n').filter(Boolean).length;
} catch {
return 0;
}
}
describe('codex model probe (#2477)', () => {
test('successful round trip -> MODEL_OK, cached, no re-invocation', () => {
const f = makeFixture();
try {
const first = runProbe(f, 'ok');
expect(first.stdout.trim()).toBe('MODEL_OK');
expect(first.status).toBe(0);
expect(invocations(f)).toBe(1);
expect(fs.existsSync(path.join(f.gstackHome, '.codex-model-probe'))).toBe(true);
const second = runProbe(f, 'ok');
expect(second.stdout.trim()).toBe('MODEL_OK (cached)');
expect(second.status).toBe(0);
expect(invocations(f)).toBe(1); // cache hit: stub not re-invoked
} finally {
fs.rmSync(f.home, { recursive: true, force: true });
}
});
test('model 400 -> MODEL_UNUSABLE with config.toml hints, exit 1, negative-cached', () => {
const f = makeFixture();
try {
const r = runProbe(f, 'model400');
expect(r.stdout).toContain('MODEL_UNUSABLE');
expect(r.stdout).toContain('config.toml');
expect(r.stdout).toContain('model_migrations');
// Surfaces the actual rejection so the user sees WHICH model.
expect(r.stdout).toContain('gpt-5.4');
expect(r.status).toBe(1);
// The deterministic 400 is config-driven: re-probing every preflight
// charged the user a 30s round trip + real tokens per review section.
// A second run within the 15-min TTL must NOT re-invoke codex, and must
// keep the exit-1 + hints contract so callers can't tell the difference.
expect(invocations(f)).toBe(1);
const second = runProbe(f, 'model400');
expect(second.stdout).toContain('MODEL_UNUSABLE (cached)');
expect(second.stdout).toContain('config.toml');
expect(second.status).toBe(1);
expect(invocations(f)).toBe(1);
} finally {
fs.rmSync(f.home, { recursive: true, force: true });
}
});
test('config.toml change re-probes past a cached MODEL_UNUSABLE (the recovery path)', () => {
const f = makeFixture();
try {
runProbe(f, 'model400');
expect(invocations(f)).toBe(1);
// Fixing the model pin changes the mtime signature — the negative cache
// must not outlive the config it condemned.
fs.writeFileSync(path.join(f.codexHome, 'config.toml'), 'model = "gpt-5.5"\n');
const future = Date.now() / 1000 + 10;
fs.utimesSync(path.join(f.codexHome, 'config.toml'), future, future);
const r = runProbe(f, 'ok');
expect(r.stdout.trim()).toBe('MODEL_OK');
expect(r.status).toBe(0);
expect(invocations(f)).toBe(2);
} finally {
fs.rmSync(f.home, { recursive: true, force: true });
}
});
test('transient failure -> inconclusive, FAIL-OPEN exit 0', () => {
const f = makeFixture();
try {
const r = runProbe(f, 'transient');
expect(r.stdout).toContain('MODEL_PROBE_INCONCLUSIVE');
expect(r.status).toBe(0);
} finally {
fs.rmSync(f.home, { recursive: true, force: true });
}
});
test('TTL expiry: a cached MODEL_OK older than 3600s re-probes (T5)', () => {
const f = makeFixture();
try {
runProbe(f, 'ok');
expect(invocations(f)).toBe(1);
// Backdate the cache line's timestamp past the 1h TTL, keeping the
// signature valid — TTL alone must force the re-probe.
const cachePath = path.join(f.gstackHome, '.codex-model-probe');
const [status, ts, sig] = fs.readFileSync(cachePath, 'utf-8').trim().split(' ');
expect(status).toBe('MODEL_OK');
fs.writeFileSync(cachePath, `MODEL_OK ${Number(ts) - 3700} ${sig}\n`);
const r = runProbe(f, 'ok');
expect(r.stdout.trim()).toBe('MODEL_OK'); // not "(cached)"
expect(invocations(f)).toBe(2); // re-probed
} finally {
fs.rmSync(f.home, { recursive: true, force: true });
}
});
test('auth.json mtime change invalidates the cached MODEL_OK (T5: re-login re-probes)', () => {
const f = makeFixture();
try {
runProbe(f, 'ok');
expect(invocations(f)).toBe(1);
// A re-login rewrites auth.json; the mtime signature must invalidate
// the cache even though config.toml is untouched.
const future = Date.now() / 1000 + 10;
fs.utimesSync(path.join(f.codexHome, 'auth.json'), future, future);
const r = runProbe(f, 'ok');
expect(r.stdout.trim()).toBe('MODEL_OK');
expect(invocations(f)).toBe(2); // re-probed
} finally {
fs.rmSync(f.home, { recursive: true, force: true });
}
});
test('config.toml change invalidates the cached MODEL_OK', () => {
const f = makeFixture();
try {
runProbe(f, 'ok');
expect(invocations(f)).toBe(1);
// Change the model pin; mtime signature must invalidate the cache.
fs.writeFileSync(path.join(f.codexHome, 'config.toml'), 'model = "gpt-5.5"\n');
const future = Date.now() / 1000 + 10;
fs.utimesSync(path.join(f.codexHome, 'config.toml'), future, future);
const r = runProbe(f, 'ok');
expect(r.stdout.trim()).toBe('MODEL_OK');
expect(invocations(f)).toBe(2); // re-probed
} finally {
fs.rmSync(f.home, { recursive: true, force: true });
}
});
});
+104
View File
@@ -0,0 +1,104 @@
/**
* Running-under-Codex detection (#2519, maintainer decision 7).
*
* /review executed inside a Codex host used to spawn nested codex
* specialists — the same model reviewing itself at multiplied token cost
* (observed: 15M tokens for one /review). A live Codex session exports
* CODEX_THREAD_ID / CODEX_SANDBOX into every shell it spawns (verified
* against a live `codex exec 'env | grep -i codex'` capture on codex
* 0.147.0: CODEX_THREAD_ID, CODEX_SANDBOX=seatbelt,
* CODEX_SANDBOX_NETWORK_DISABLED=1, CODEX_CI=1). The shared codexPreflight
* presence-probes those vars and yields CODEX_MODE=under_codex, skipping
* nested spawns with a one-line notice; GSTACK_FORCE_CODEX_REVIEW=1
* overrides.
*/
import { describe, test, expect } from 'bun:test';
import { spawnSync } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import { codexPreflight } from '../scripts/resolvers/constants';
const ROOT = path.resolve(import.meta.dir, '..');
/** Extract the runnable bash from the rendered preflight (strip fences/prose). */
function preflightBash(): string {
const rendered = codexPreflight({ disabledBehavior: 'codex-only' });
const start = rendered.indexOf('```bash') + '```bash'.length;
const end = rendered.indexOf('```', start);
return rendered.slice(start, end);
}
function runPreflight(env: Record<string, string>): string {
const result = spawnSync('bash', ['-c', `set +e\n${preflightBash()}`], {
env: {
// Minimal PATH without codex so the not_installed branch is reachable
// and no real gstack-config/codex runs. The block's fallbacks
// (`|| echo enabled`) keep it self-contained.
PATH: '/usr/bin:/bin',
HOME: '/nonexistent-home',
...env,
},
timeout: 10000,
});
return (result.stdout ?? '').toString();
}
describe('under-codex detection bash (#2519)', () => {
test('CODEX_THREAD_ID present -> under_codex', () => {
const out = runPreflight({ CODEX_THREAD_ID: '01a00ba9-ff91-7143-b424-c2d9b0cc89ff' });
expect(out).toContain('CODEX_MODE: under_codex');
});
test('CODEX_SANDBOX present (no thread id) -> under_codex', () => {
const out = runPreflight({ CODEX_SANDBOX: 'seatbelt' });
expect(out).toContain('CODEX_MODE: under_codex');
});
test('GSTACK_FORCE_CODEX_REVIEW=1 overrides the presence probe', () => {
const out = runPreflight({
CODEX_THREAD_ID: '01a00ba9-ff91-7143-b424-c2d9b0cc89ff',
CODEX_SANDBOX: 'seatbelt',
GSTACK_FORCE_CODEX_REVIEW: '1',
});
expect(out).not.toContain('CODEX_MODE: under_codex');
// With codex absent from the restricted PATH, the forced probe falls
// through to the ordinary availability chain.
expect(out).toContain('CODEX_MODE: not_installed');
});
test('no CODEX_* env -> ordinary availability chain', () => {
const out = runPreflight({});
expect(out).not.toContain('CODEX_MODE: under_codex');
expect(out).toContain('CODEX_MODE: not_installed');
});
});
describe('under-codex wiring renders (#2519)', () => {
test('rendered adversarial section carries the probe + override + notice', () => {
const rendered = fs.readFileSync(
path.join(ROOT, 'ship', 'sections', 'adversarial.md'),
'utf-8',
);
expect(rendered).toContain('CODEX_THREAD_ID');
expect(rendered).toContain('GSTACK_FORCE_CODEX_REVIEW');
expect(rendered).toContain('under_codex');
expect(rendered).toContain('nested codex passes skipped');
});
test('rendered codex skill stops with the one-line notice when under codex', () => {
const rendered = fs.readFileSync(path.join(ROOT, 'codex', 'SKILL.md'), 'utf-8');
expect(rendered).toContain('UNDER_CODEX');
expect(rendered).toContain('GSTACK_FORCE_CODEX_REVIEW=1');
});
test('all three codexPreflight consumers render the probe', () => {
for (const file of [
path.join(ROOT, 'ship', 'sections', 'adversarial.md'),
path.join(ROOT, 'plan-ceo-review', 'sections', 'review-sections.md'),
path.join(ROOT, 'document-release', 'sections', 'release-body.md'),
]) {
const rendered = fs.readFileSync(file, 'utf-8');
expect(rendered).toContain('under_codex');
}
});
});
+65
View File
@@ -0,0 +1,65 @@
/**
* Deprecated codex web-search flag tripwire (#2525).
*
* codex >=0.144 deprecates `--enable web_search_cached` (its `--enable
* <FEATURE>` surface now means `-c features.<name>=true`); the replacement
* is `-c 'web_search="cached"'`, owned by ONE constant:
* CODEX_WEB_SEARCH_FLAG in scripts/resolvers/constants.ts. Resolvers
* interpolate it; templates reference {{CODEX_WEB_SEARCH_FLAG}}.
*
* These tests fail CI if the deprecated spelling re-enters any source
* (resolver, template, helper) or any rendered SKILL.md / section / golden.
*/
import { describe, test, expect } from 'bun:test';
import { execSync } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import { CODEX_WEB_SEARCH_FLAG } from '../scripts/resolvers/constants';
const ROOT = path.join(import.meta.dir, '..');
const DEPRECATED = '--enable web_search_cached';
function grepRepo(pattern: string, includes: string[]): string[] {
const includeArgs = includes.map((i) => `--include='${i}'`).join(' ');
const out = execSync(
`grep -rln ${includeArgs} -e '${pattern}' "${ROOT}" || true`,
{ encoding: 'utf-8' },
);
return out
.split('\n')
.filter(Boolean)
.filter((f) => !f.includes('node_modules'))
// The workspace-local .claude/ install is not generated output and can
// carry dangling symlinks from unrelated sessions.
.filter((f) => !f.includes('/.claude/'))
.filter((f) => !f.endsWith('test/codex-web-search-flag.test.ts'));
}
describe('deprecated codex web-search flag is gone (#2525)', () => {
test('the replacement flag has exactly the documented shape', () => {
expect(CODEX_WEB_SEARCH_FLAG).toBe(`-c 'web_search="cached"'`);
});
test('no rendered SKILL.md or section carries the deprecated flag', () => {
const hits = grepRepo(DEPRECATED, ['SKILL.md', '*.md']);
expect(hits).toEqual([]);
});
test('no source file (resolver, template, helper) carries the deprecated flag', () => {
const hits = grepRepo(DEPRECATED, ['*.ts', '*.tmpl']);
expect(hits).toEqual([]);
});
test('rendered codex skill actually resolves the token to the live flag', () => {
const rendered = fs.readFileSync(path.join(ROOT, 'codex', 'SKILL.md'), 'utf-8');
expect(rendered).toContain(CODEX_WEB_SEARCH_FLAG);
expect(rendered).not.toContain('{{CODEX_WEB_SEARCH_FLAG}}');
});
test('rendered autoplan skill resolves the token at every inline site', () => {
const rendered = fs.readFileSync(path.join(ROOT, 'autoplan', 'SKILL.md'), 'utf-8');
const count = rendered.split(CODEX_WEB_SEARCH_FLAG).length - 1;
expect(count).toBeGreaterThanOrEqual(4);
expect(rendered).not.toContain('{{CODEX_WEB_SEARCH_FLAG}}');
});
});
+157
View File
@@ -178,3 +178,160 @@ describe('gstack-diff-scope', () => {
expect(scope).toHaveProperty('SCOPE_AUTH');
});
});
// ---------------------------------------------------------------------------
// #2526 / #2455 / #2299 — glob classes, exit-code contract, dirty-tree union,
// independent categories.
// ---------------------------------------------------------------------------
function runScopeFull(dir: string): { vars: Record<string, string>; status: number; stdout: string } {
const result = spawnSync('bash', [SCRIPT, 'main'], {
cwd: dir, stdio: 'pipe', timeout: 5000,
});
const stdout = result.stdout.toString();
const vars: Record<string, string> = {};
for (const line of stdout.trim().split('\n')) {
if (line.startsWith('#')) continue;
const [key, val] = line.split('=');
if (key && val) vars[key] = val;
}
return { vars, status: result.status ?? -1, stdout };
}
describe('glob classes (table-driven, #2526 + #2455)', () => {
// One row per glob class the case arms cover. `expects` lists every scope
// that MUST be true; categories are independent (#2299), so extra true
// flags beyond `expects` are asserted per-row via `alsoFalse`.
const TABLE: { name: string; file: string; expects: string[]; alsoFalse?: string[] }[] = [
// API — the #2526 headline: root-level api/ (Vercel/serverless layout).
{ name: 'root-level api/ (#2526)', file: 'api/ipospays/process-payment.ts', expects: ['SCOPE_API', 'SCOPE_BACKEND'] },
{ name: 'nested */api/*', file: 'src/api/foo.ts', expects: ['SCOPE_API', 'SCOPE_BACKEND'] },
{ name: 'controller name', file: 'app/controllers/users_controller.rb', expects: ['SCOPE_API', 'SCOPE_BACKEND'] },
{ name: 'openapi schema', file: 'openapi.yaml', expects: ['SCOPE_API', 'SCOPE_CONFIG'] },
// Migrations — root-level migrations/ + the data_migrate gem's db/data (#2455).
{ name: 'root-level migrations/ (#2526)', file: 'migrations/0001_initial.sql', expects: ['SCOPE_MIGRATIONS'] },
{ name: 'nested */migrations/*', file: 'app/migrations/0001_initial.py', expects: ['SCOPE_MIGRATIONS', 'SCOPE_BACKEND'] },
{
name: 'db/data data migration (#2455) — MIGRATIONS and BACKEND both',
file: 'db/data/20260804123456_backfill_x.rb',
expects: ['SCOPE_MIGRATIONS', 'SCOPE_BACKEND'],
},
{ name: 'data_migrations/ dir', file: 'data_migrations/backfill.rb', expects: ['SCOPE_MIGRATIONS', 'SCOPE_BACKEND'] },
{ name: 'db/migrate schema migration', file: 'db/migrate/20260330_create_users.rb', expects: ['SCOPE_MIGRATIONS', 'SCOPE_BACKEND'] },
// Independent categories (#2299): test-suffixed frontend files carry BOTH.
{
name: 'Button.test.jsx is FRONTEND and TESTS (#2299)',
file: 'src/Button.test.jsx',
expects: ['SCOPE_FRONTEND', 'SCOPE_TESTS'],
alsoFalse: ['SCOPE_BACKEND'], // frontend files never claim backend
},
{ name: 'util.test.ts is BACKEND and TESTS (#2299)', file: 'src/util.test.ts', expects: ['SCOPE_BACKEND', 'SCOPE_TESTS'] },
{ name: 'auth code carries AUTH and BACKEND', file: 'src/lib/auth.ts', expects: ['SCOPE_AUTH', 'SCOPE_BACKEND'] },
// Plain classes unchanged.
{ name: 'plain component', file: 'src/A.jsx', expects: ['SCOPE_FRONTEND'], alsoFalse: ['SCOPE_TESTS', 'SCOPE_BACKEND'] },
{ name: 'plain backend', file: 'server.go', expects: ['SCOPE_BACKEND'], alsoFalse: ['SCOPE_FRONTEND'] },
{ name: 'docs', file: 'docs/guide.md', expects: ['SCOPE_DOCS'] },
{ name: 'prompts', file: 'app/services/prompt_builder.rb', expects: ['SCOPE_PROMPTS', 'SCOPE_BACKEND'] },
];
for (const row of TABLE) {
test(row.name, () => {
const { vars, status } = runScopeFull(createRepo([row.file]));
expect(status).toBe(0);
for (const key of row.expects) {
expect(`${key}=${vars[key]}`).toBe(`${key}=true`);
}
for (const key of row.alsoFalse ?? []) {
expect(`${key}=${vars[key]}`).toBe(`${key}=false`);
}
expect(vars.SCOPE_ERROR).toBeUndefined();
});
}
});
describe('exit-code contract (#2526)', () => {
test('clean tree, no changes → all false, exit 0, no SCOPE_ERROR', () => {
const dir = createRepo([]);
const { vars, status } = runScopeFull(dir);
expect(status).toBe(0);
expect(vars.SCOPE_ERROR).toBeUndefined();
expect(Object.values(vars).every((v) => v === 'false')).toBe(true);
});
test('changed files but ZERO matches → SCOPE_ERROR=unmatched + exit 2 + paths listed', () => {
const dir = createRepo(['Makefile.custom', 'weird/layout.xyz']);
const { vars, status, stdout } = runScopeFull(dir);
expect(status).toBe(2);
expect(vars.SCOPE_ERROR).toBe('unmatched');
expect(stdout).toContain('# unmatched: weird/layout.xyz');
// Still prints all nine flags so `source <(...)` consumers get vars.
expect(vars.SCOPE_FRONTEND).toBe('false');
expect(vars.SCOPE_API).toBe('false');
});
test('unresolvable base → SCOPE_ERROR=no_base + exit 2 (a green would mean "could not look")', () => {
const dir = createRepo(['app.ts']);
const result = spawnSync('bash', [SCRIPT, 'no-such-branch'], { cwd: dir, stdio: 'pipe', timeout: 5000 });
expect(result.status).toBe(2);
const out = result.stdout.toString();
expect(out).toContain('SCOPE_ERROR=no_base');
expect(out).toContain('SCOPE_FRONTEND=false');
});
test('output stays shell-safe for sourcing consumers in every state', () => {
// eval'd rather than `source <(...)`: macOS system bash 3.2 sources a
// process substitution as 0 bytes (st_size-based buffer on a FIFO), which
// would test the shell, not the script. The property under test is that
// every output line is a valid assignment or comment.
const dir = createRepo(['weird/layout.xyz']);
const result = spawnSync('bash', ['-c', `out="$(bash "${SCRIPT}" main)"; eval "$out"; echo "ERR=$SCOPE_ERROR FRONT=$SCOPE_FRONTEND"`], {
cwd: dir, stdio: 'pipe', timeout: 5000,
});
expect(result.stdout.toString()).toContain('ERR=unmatched FRONT=false');
});
});
describe('uncommitted work is visible (#2299)', () => {
test('uncommitted change on a branch with no commits sets the scope', () => {
const dir = mkdtempSync(join(tmpdir(), 'diff-scope-dirty-'));
dirs.push(dir);
const run = (cmd: string, args: string[]) => spawnSync(cmd, args, { cwd: dir, stdio: 'pipe', timeout: 5000 });
run('git', ['init', '-b', 'main']);
run('git', ['config', 'user.email', 't@t.com']);
run('git', ['config', 'user.name', 'T']);
mkdirSync(join(dir, 'src'), { recursive: true });
writeFileSync(join(dir, 'src', 'Button.jsx'), '// x\n');
run('git', ['add', '.']);
run('git', ['commit', '-m', 'initial']);
run('git', ['checkout', '-b', 'feat/x']); // no commits on the branch
writeFileSync(join(dir, 'src', 'Button.jsx'), '// modified, uncommitted\n');
const { vars, status } = runScopeFull(dir);
expect(status).toBe(0);
expect(vars.SCOPE_FRONTEND).toBe('true'); // was false pre-fix (all-false early exit)
});
test('an UNTRACKED new migration sets SCOPE_MIGRATIONS (reviewers must see it)', () => {
const dir = mkdtempSync(join(tmpdir(), 'diff-scope-untracked-'));
dirs.push(dir);
const run = (cmd: string, args: string[]) => spawnSync(cmd, args, { cwd: dir, stdio: 'pipe', timeout: 5000 });
run('git', ['init', '-b', 'main']);
run('git', ['config', 'user.email', 't@t.com']);
run('git', ['config', 'user.name', 'T']);
writeFileSync(join(dir, 'README.md'), '# t\n');
run('git', ['add', '.']);
run('git', ['commit', '-m', 'initial']);
mkdirSync(join(dir, 'db', 'data'), { recursive: true });
writeFileSync(join(dir, 'db', 'data', '20260816_backfill.rb'), '# data migration\n');
const { vars, status } = runScopeFull(dir);
expect(status).toBe(0);
expect(vars.SCOPE_MIGRATIONS).toBe('true');
expect(vars.SCOPE_BACKEND).toBe('true');
});
test('non-ASCII path still matches extension globs (NUL-safe file listing, #2526)', () => {
const dir = createRepo(['docs/M2 — Notes.md']);
const { vars, status } = runScopeFull(dir);
expect(status).toBe(0);
expect(vars.SCOPE_DOCS).toBe('true'); // pre-fix: git's octal quoting defeated *.md
});
});
+1 -1
View File
@@ -17,7 +17,7 @@ import * as os from 'os';
import * as path from 'path';
import { listReceipts, sha256Hex } from '../lib/egress-receipt';
const ROOT = path.resolve(new URL(import.meta.url).pathname, '..', '..');
const ROOT = path.resolve(import.meta.path, '..', '..');
const LIB = path.join(ROOT, 'bin', 'gstack-egress-lib.sh');
const received: string[] = [];
+1 -1
View File
@@ -28,7 +28,7 @@ import { describe, test, expect } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';
const ROOT = path.resolve(new URL(import.meta.url).pathname, '..', '..');
const ROOT = path.resolve(import.meta.path, '..', '..');
function read(rel: string): string {
return fs.readFileSync(path.join(ROOT, rel), 'utf-8');
+1 -1
View File
@@ -31,7 +31,7 @@ import {
writeReceipt,
} from '../lib/egress-receipt';
const ROOT = path.resolve(new URL(import.meta.url).pathname, '..', '..');
const ROOT = path.resolve(import.meta.path, '..', '..');
let home: string;
+95
View File
@@ -0,0 +1,95 @@
/**
* Empty `find | xargs ls -t` must not fall through to cwd (#2483).
*
* GNU xargs runs `ls -t` once even on EMPTY input, and `ls -t` with no
* operands lists the CURRENT DIRECTORY — so on a fresh install (no
* ceo-plans / checkpoints / plans yet) a random cwd .md becomes "the plan"
* or "the latest checkpoint". `xargs -r` pins the BSD skip-on-empty
* behavior on both GNU and BSD (same shape as the landed
* bin/gstack-codex-session-import fix, #2482).
*
* Re-derived from community PR #2483 by @tranthanhnhatkhoa.
*/
import { describe, test, expect } from 'bun:test';
import { execSync } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { HOST_PATHS } from '../scripts/resolvers/types';
import type { TemplateContext } from '../scripts/resolvers/types';
import { generateContextRecovery } from '../scripts/resolvers/preamble/generate-context-recovery';
const ROOT = path.join(import.meta.dir, '..');
function makeCtx(): TemplateContext {
return {
skillName: 'test-skill',
tmplPath: 'test.tmpl',
host: 'claude',
paths: HOST_PATHS.claude,
preambleTier: 2,
};
}
describe('empty find must not fall through to cwd (#2483)', () => {
test('no resolver emits a bare `xargs ls -t` (must be `xargs -r ls -t`)', () => {
const out = execSync(
`grep -rn "xargs ls -t" "${path.join(ROOT, 'scripts')}" "${path.join(ROOT, 'bin')}" || true`,
{ encoding: 'utf-8' },
);
expect(out.trim()).toBe('');
});
test('rendered Context Recovery uses the guarded form at both find sites', () => {
const rendered = generateContextRecovery(makeCtx());
const bareSites = rendered.split('xargs ls -t').length - 1;
expect(bareSites).toBe(0);
const guardedSites = rendered.split('xargs -r ls -t').length - 1;
expect(guardedSites).toBe(2);
});
test('live block: empty checkpoints dir yields NO checkpoint, not a cwd file', () => {
const rendered = generateContextRecovery(makeCtx());
const m = rendered.match(/_LATEST_CP=\$\(.*\)/);
expect(m).not.toBeNull();
const line = m![0];
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-home-'));
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-cwd-'));
try {
// Fresh install shape: the checkpoints dir exists but is EMPTY,
// and the cwd holds a decoy markdown file.
const proj = path.join(home, 'projects', 'unknown');
fs.mkdirSync(path.join(proj, 'checkpoints'), { recursive: true });
fs.writeFileSync(path.join(cwd, 'DECOY.md'), '# not a checkpoint\n');
const script = `_PROJ="${proj}"\n${line.replace(/\$\{_PROJ\}|"\$_PROJ"/g, '"$_PROJ"')}\necho "LATEST_CP=[$_LATEST_CP]"`;
const out = execSync(`bash -c '${script.replace(/'/g, `'\\''`)}'`, {
cwd,
encoding: 'utf-8',
});
expect(out).toContain('LATEST_CP=[]');
expect(out).not.toContain('DECOY.md');
} finally {
fs.rmSync(home, { recursive: true, force: true });
fs.rmSync(cwd, { recursive: true, force: true });
}
});
test('no generated SKILL.md carries the unguarded form', () => {
const out = execSync(
`grep -rln "xargs ls -t" --include=SKILL.md "${ROOT}" || true`,
{ encoding: 'utf-8' },
);
// node_modules and vendored trees are not generated output; nothing in
// the repo's generated skills may carry the unguarded form.
const hits = out
.split('\n')
.filter(Boolean)
.filter((f) => !f.includes('node_modules'))
// The workspace-local .claude/ install is not generated output and can
// carry dangling symlinks from unrelated sessions.
.filter((f) => !f.includes('/.claude/'));
expect(hits).toEqual([]);
});
});
+17 -11
View File
@@ -112,9 +112,11 @@ else
fi
~/.claude/skills/gstack/bin/gstack-timeline-log '{"skill":"ship","event":"started","branch":"'"$_BRANCH"'","session":"'"$_SESSION_ID"'"}' 2>/dev/null &
_HAS_ROUTING="no"
if [ -f CLAUDE.md ] && grep -q "## Skill routing" CLAUDE.md 2>/dev/null; then
_HAS_ROUTING="yes"
fi
for _RF in CLAUDE.md AGENTS.md; do
if [ -f "$_RF" ] && grep -q "## Skill routing" "$_RF" 2>/dev/null; then
_HAS_ROUTING="yes"
fi
done
_ROUTING_DECLINED=$(~/.claude/skills/gstack/bin/gstack-config get routing_declined 2>/dev/null || echo "false")
echo "HAS_ROUTING: $_HAS_ROUTING"
echo "ROUTING_DECLINED: $_ROUTING_DECLINED"
@@ -508,10 +510,13 @@ _BRAIN_SYNC_MODE=$("$_BRAIN_CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || e
# Detect remote-MCP mode (Path 4 of /setup-gbrain). Local artifacts sync is
# a no-op in remote mode; the brain server pulls from GitHub/GitLab on its
# own cadence. Read claude.json directly to keep this preamble fast (no
# subprocess to claude CLI on every skill start).
# subprocess to claude CLI on every skill start). Both registration scopes
# are read (#2499): user scope, then the nearest-ancestor project scope.
_GBRAIN_MCP_MODE="none"
_GBRAIN_MCP_ENTRY=""
if command -v jq >/dev/null 2>&1 && [ -f "$HOME/.claude.json" ]; then
_GBRAIN_MCP_TYPE=$(jq -r '.mcpServers.gbrain.type // .mcpServers.gbrain.transport // empty' "$HOME/.claude.json" 2>/dev/null)
_GBRAIN_MCP_ENTRY=$(jq -c --arg cwd "$PWD" '.mcpServers.gbrain // ((.projects // {}) | to_entries | map(select((.key as $k | $cwd == $k or ($cwd | startswith($k + "/"))) and ((try .value.mcpServers.gbrain catch null) != null))) | sort_by(.key | length) | last | .value.mcpServers.gbrain) // empty' "$HOME/.claude.json" 2>/dev/null)
_GBRAIN_MCP_TYPE=$(printf '%s' "$_GBRAIN_MCP_ENTRY" | jq -r '.type // .transport // empty' 2>/dev/null)
case "$_GBRAIN_MCP_TYPE" in
url|http|sse) _GBRAIN_MCP_MODE="remote-http" ;;
stdio) _GBRAIN_MCP_MODE="local-stdio" ;;
@@ -532,6 +537,7 @@ if [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then
_BRAIN_DO_PULL=1
if [ -f "$_BRAIN_LAST_PULL_FILE" ]; then
_BRAIN_LAST=$(cat "$_BRAIN_LAST_PULL_FILE" 2>/dev/null || echo 0)
case "$_BRAIN_LAST" in ''|*[!0-9]*) _BRAIN_LAST=0 ;; esac
_BRAIN_AGE=$(( _BRAIN_NOW - _BRAIN_LAST ))
[ "$_BRAIN_AGE" -lt 86400 ] && _BRAIN_DO_PULL=0
fi
@@ -545,7 +551,7 @@ fi
if [ "$_GBRAIN_MCP_MODE" = "remote-http" ]; then
# Remote-MCP mode: local artifacts sync is a no-op (brain admin's server
# pulls from GitHub/GitLab). Show the user this is by design, not broken.
_GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|')
_GBRAIN_HOST=$(printf '%s' "${_GBRAIN_MCP_ENTRY:-}" | jq -r '.url // empty' 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|' | head -1 | tr -cd 'A-Za-z0-9._-')
echo "ARTIFACTS_SYNC: remote-mode (managed by brain server ${_GBRAIN_HOST:-remote})"
elif [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then
_BRAIN_QUEUE_DEPTH=0
@@ -630,8 +636,8 @@ eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)"
_PROJ="${GSTACK_HOME:-$HOME/.gstack}/projects/${SLUG:-unknown}"
if [ -d "$_PROJ" ]; then
echo "--- RECENT ARTIFACTS ---"
find "$_PROJ/ceo-plans" "$_PROJ/checkpoints" -type f -name "*.md" 2>/dev/null | xargs ls -t 2>/dev/null | head -3
[ -f "$_PROJ/${_BRANCH}-reviews.jsonl" ] && echo "REVIEWS: $(wc -l < "$_PROJ/${_BRANCH}-reviews.jsonl" | tr -d ' ') entries"
find "$_PROJ/ceo-plans" "$_PROJ/checkpoints" -type f -name "*.md" 2>/dev/null | xargs -r ls -t 2>/dev/null | head -3
[ -f "$_PROJ/${BRANCH:-unknown}-reviews.jsonl" ] && echo "REVIEWS: $(wc -l < "$_PROJ/${BRANCH:-unknown}-reviews.jsonl" | tr -d ' ') entries"
[ -f "$_PROJ/timeline.jsonl" ] && tail -5 "$_PROJ/timeline.jsonl"
if [ -f "$_PROJ/timeline.jsonl" ]; then
_LAST=$(grep "\"branch\":\"${_BRANCH}\"" "$_PROJ/timeline.jsonl" 2>/dev/null | grep '"event":"completed"' | tail -1)
@@ -639,7 +645,7 @@ if [ -d "$_PROJ" ]; then
_RECENT_SKILLS=$(grep "\"branch\":\"${_BRANCH}\"" "$_PROJ/timeline.jsonl" 2>/dev/null | grep '"event":"completed"' | tail -3 | grep -o '"skill":"[^"]*"' | sed 's/"skill":"//;s/"//' | tr '\n' ',')
[ -n "$_RECENT_SKILLS" ] && echo "RECENT_PATTERN: $_RECENT_SKILLS"
fi
_LATEST_CP=$(find "$_PROJ/checkpoints" -name "*.md" -type f 2>/dev/null | xargs ls -t 2>/dev/null | head -1)
_LATEST_CP=$(find "$_PROJ/checkpoints" -name "*.md" -type f 2>/dev/null | xargs -r ls -t 2>/dev/null | head -1)
[ -n "$_LATEST_CP" ] && echo "LATEST_CHECKPOINT: $_LATEST_CP"
if [ -f "$_PROJ/decisions.active.json" ]; then
echo "--- ACTIVE DECISIONS (recent, scope-relevant) ---"
@@ -715,7 +721,7 @@ If you are looping on the same diagnostic, same file, or failed fix variants, ST
## Question Tuning (skip entirely if `QUESTION_TUNING: false`)
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `printf '%s' "<question summary>" | ~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>" --summary-stdin` (piped summary feeds the one-way keyword net, #2024). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
Before each AskUserQuestion, choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `printf '%s' "<question summary>" | ~/.claude/skills/gstack/bin/gstack-question-preference --check "<id>" --summary-stdin` (piped summary feeds the one-way keyword net, #2024). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
**Embed the question_id as a marker in the question text** so hooks can identify it deterministically (plan-tune cathedral T14 / D18 progressive markers). Append `<gstack-qid:{question_id}>` somewhere in the rendered question (the leading line or trailing line is fine; the marker doesn't render visibly to the user when wrapped in HTML-style angle brackets, but the hook strips it). Without the marker the PreToolUse enforcement hook treats the AUQ as observed-only and never auto-decides — so always include it when the question matches a registered `question_id`.
@@ -1105,7 +1111,7 @@ stay agent judgment; the slot pick stays `gstack-next-version`.
```bash
bun run ~/.claude/skills/gstack/bin/gstack-version-bump write --version "$NEW_VERSION"
```
The CLI validates the 4-digit `MAJOR.MINOR.PATCH.MICRO` pattern and writes **both** VERSION and package.json. On a half-write (VERSION written, package.json failed) it exits 3 — re-run, and classify will report DRIFT_STALE_PKG for `repair` to fix.
The CLI validates the version pattern (4-digit `MAJOR.MINOR.PATCH.MICRO`; 3-digit for repos whose pinned version source uses plain semver) and writes VERSION, the manifest, and the manifest's npm lockfiles (`package-lock.json` / `npm-shrinkwrap.json`) when they already exist — never created. The manifest is resolved as `--package-json-path` → `.gstack/package-json-path` → `./package.json`, so a repo whose only Node package lives in a subdirectory (`web/`, `app/`) is covered by a one-line pin instead of silently getting a VERSION-only bump. npm rejects 4-component versions, so the manifest and lockfiles carry the npm-valid 3-digit translation (`1.67.0.0` → `1.67.0`); VERSION stays the 4-digit source of truth and classify judges drift against the translated form. On a half-write it exits 3 — re-run, and classify will report DRIFT_STALE_PKG for `repair` to fix.
5. **Record the release decision** (durable cross-session memory). The bump level is a real decision the next session should not re-derive blind:
```bash
+21 -14
View File
@@ -98,9 +98,11 @@ else
fi
$GSTACK_BIN/gstack-timeline-log '{"skill":"ship","event":"started","branch":"'"$_BRANCH"'","session":"'"$_SESSION_ID"'"}' 2>/dev/null &
_HAS_ROUTING="no"
if [ -f AGENTS.md ] && grep -q "## Skill routing" AGENTS.md 2>/dev/null; then
_HAS_ROUTING="yes"
fi
for _RF in AGENTS.md AGENTS.md; do
if [ -f "$_RF" ] && grep -q "## Skill routing" "$_RF" 2>/dev/null; then
_HAS_ROUTING="yes"
fi
done
_ROUTING_DECLINED=$($GSTACK_BIN/gstack-config get routing_declined 2>/dev/null || echo "false")
echo "HAS_ROUTING: $_HAS_ROUTING"
echo "ROUTING_DECLINED: $_ROUTING_DECLINED"
@@ -494,10 +496,13 @@ _BRAIN_SYNC_MODE=$("$_BRAIN_CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || e
# Detect remote-MCP mode (Path 4 of /setup-gbrain). Local artifacts sync is
# a no-op in remote mode; the brain server pulls from GitHub/GitLab on its
# own cadence. Read claude.json directly to keep this preamble fast (no
# subprocess to claude CLI on every skill start).
# subprocess to claude CLI on every skill start). Both registration scopes
# are read (#2499): user scope, then the nearest-ancestor project scope.
_GBRAIN_MCP_MODE="none"
_GBRAIN_MCP_ENTRY=""
if command -v jq >/dev/null 2>&1 && [ -f "$HOME/.claude.json" ]; then
_GBRAIN_MCP_TYPE=$(jq -r '.mcpServers.gbrain.type // .mcpServers.gbrain.transport // empty' "$HOME/.claude.json" 2>/dev/null)
_GBRAIN_MCP_ENTRY=$(jq -c --arg cwd "$PWD" '.mcpServers.gbrain // ((.projects // {}) | to_entries | map(select((.key as $k | $cwd == $k or ($cwd | startswith($k + "/"))) and ((try .value.mcpServers.gbrain catch null) != null))) | sort_by(.key | length) | last | .value.mcpServers.gbrain) // empty' "$HOME/.claude.json" 2>/dev/null)
_GBRAIN_MCP_TYPE=$(printf '%s' "$_GBRAIN_MCP_ENTRY" | jq -r '.type // .transport // empty' 2>/dev/null)
case "$_GBRAIN_MCP_TYPE" in
url|http|sse) _GBRAIN_MCP_MODE="remote-http" ;;
stdio) _GBRAIN_MCP_MODE="local-stdio" ;;
@@ -518,6 +523,7 @@ if [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then
_BRAIN_DO_PULL=1
if [ -f "$_BRAIN_LAST_PULL_FILE" ]; then
_BRAIN_LAST=$(cat "$_BRAIN_LAST_PULL_FILE" 2>/dev/null || echo 0)
case "$_BRAIN_LAST" in ''|*[!0-9]*) _BRAIN_LAST=0 ;; esac
_BRAIN_AGE=$(( _BRAIN_NOW - _BRAIN_LAST ))
[ "$_BRAIN_AGE" -lt 86400 ] && _BRAIN_DO_PULL=0
fi
@@ -531,7 +537,7 @@ fi
if [ "$_GBRAIN_MCP_MODE" = "remote-http" ]; then
# Remote-MCP mode: local artifacts sync is a no-op (brain admin's server
# pulls from GitHub/GitLab). Show the user this is by design, not broken.
_GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|')
_GBRAIN_HOST=$(printf '%s' "${_GBRAIN_MCP_ENTRY:-}" | jq -r '.url // empty' 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|' | head -1 | tr -cd 'A-Za-z0-9._-')
echo "ARTIFACTS_SYNC: remote-mode (managed by brain server ${_GBRAIN_HOST:-remote})"
elif [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then
_BRAIN_QUEUE_DEPTH=0
@@ -616,8 +622,8 @@ eval "$($GSTACK_BIN/gstack-slug 2>/dev/null)"
_PROJ="${GSTACK_HOME:-$HOME/.gstack}/projects/${SLUG:-unknown}"
if [ -d "$_PROJ" ]; then
echo "--- RECENT ARTIFACTS ---"
find "$_PROJ/ceo-plans" "$_PROJ/checkpoints" -type f -name "*.md" 2>/dev/null | xargs ls -t 2>/dev/null | head -3
[ -f "$_PROJ/${_BRANCH}-reviews.jsonl" ] && echo "REVIEWS: $(wc -l < "$_PROJ/${_BRANCH}-reviews.jsonl" | tr -d ' ') entries"
find "$_PROJ/ceo-plans" "$_PROJ/checkpoints" -type f -name "*.md" 2>/dev/null | xargs -r ls -t 2>/dev/null | head -3
[ -f "$_PROJ/${BRANCH:-unknown}-reviews.jsonl" ] && echo "REVIEWS: $(wc -l < "$_PROJ/${BRANCH:-unknown}-reviews.jsonl" | tr -d ' ') entries"
[ -f "$_PROJ/timeline.jsonl" ] && tail -5 "$_PROJ/timeline.jsonl"
if [ -f "$_PROJ/timeline.jsonl" ]; then
_LAST=$(grep "\"branch\":\"${_BRANCH}\"" "$_PROJ/timeline.jsonl" 2>/dev/null | grep '"event":"completed"' | tail -1)
@@ -625,7 +631,7 @@ if [ -d "$_PROJ" ]; then
_RECENT_SKILLS=$(grep "\"branch\":\"${_BRANCH}\"" "$_PROJ/timeline.jsonl" 2>/dev/null | grep '"event":"completed"' | tail -3 | grep -o '"skill":"[^"]*"' | sed 's/"skill":"//;s/"//' | tr '\n' ',')
[ -n "$_RECENT_SKILLS" ] && echo "RECENT_PATTERN: $_RECENT_SKILLS"
fi
_LATEST_CP=$(find "$_PROJ/checkpoints" -name "*.md" -type f 2>/dev/null | xargs ls -t 2>/dev/null | head -1)
_LATEST_CP=$(find "$_PROJ/checkpoints" -name "*.md" -type f 2>/dev/null | xargs -r ls -t 2>/dev/null | head -1)
[ -n "$_LATEST_CP" ] && echo "LATEST_CHECKPOINT: $_LATEST_CP"
if [ -f "$_PROJ/decisions.active.json" ]; then
echo "--- ACTIVE DECISIONS (recent, scope-relevant) ---"
@@ -701,7 +707,7 @@ If you are looping on the same diagnostic, same file, or failed fix variants, ST
## Question Tuning (skip entirely if `QUESTION_TUNING: false`)
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `printf '%s' "<question summary>" | $GSTACK_BIN/gstack-question-preference --check "<id>" --summary-stdin` (piped summary feeds the one-way keyword net, #2024). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
Before each AskUserQuestion, choose `question_id` from `$GSTACK_ROOT/scripts/question-registry.ts` or `{skill}-{slug}`, then run `printf '%s' "<question summary>" | $GSTACK_BIN/gstack-question-preference --check "<id>" --summary-stdin` (piped summary feeds the one-way keyword net, #2024). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
**Embed the question_id as a marker in the question text** so hooks can identify it deterministically (plan-tune cathedral T14 / D18 progressive markers). Append `<gstack-qid:{question_id}>` somewhere in the rendered question (the leading line or trailing line is fine; the marker doesn't render visibly to the user when wrapped in HTML-style angle brackets, but the hook strips it). Without the marker the PreToolUse enforcement hook treats the AUQ as observed-only and never auto-decides — so always include it when the question matches a registered `question_id`.
@@ -1707,7 +1713,7 @@ Repo: {owner/repo}
```bash
setopt +o nomatch 2>/dev/null || true # zsh compat
BRANCH=$(git branch --show-current 2>/dev/null | tr '/' '-')
BRANCH=$(git branch --show-current 2>/dev/null | tr '/' '-' | tr -cd 'a-zA-Z0-9._-')
REPO=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)")
# Compute project slug for ~/.gstack/projects/ lookup
_PLAN_SLUG=$(git remote get-url origin 2>/dev/null | sed 's|.*[:/]\([^/]*/[^/]*\)\.git$|\1|;s|.*[:/]\([^/]*/[^/]*\)$|\1|' | tr '/' '-' | tr -cd 'a-zA-Z0-9._-') || true
@@ -1717,7 +1723,7 @@ for PLAN_DIR in "$HOME/.gstack/projects/$_PLAN_SLUG" "$HOME/.claude/plans" "$HOM
[ -d "$PLAN_DIR" ] || continue
PLAN=$(ls -t "$PLAN_DIR"/*.md 2>/dev/null | xargs grep -l "$BRANCH" 2>/dev/null | head -1)
[ -z "$PLAN" ] && PLAN=$(ls -t "$PLAN_DIR"/*.md 2>/dev/null | xargs grep -l "$REPO" 2>/dev/null | head -1)
[ -z "$PLAN" ] && PLAN=$(find "$PLAN_DIR" -name '*.md' -mmin -1440 -maxdepth 1 2>/dev/null | xargs ls -t 2>/dev/null | head -1)
[ -z "$PLAN" ] && PLAN=$(find "$PLAN_DIR" -name '*.md' -mmin -1440 -maxdepth 1 2>/dev/null | xargs -r ls -t 2>/dev/null | head -1)
[ -n "$PLAN" ] && break
done
[ -n "$PLAN" ] && echo "PLAN_FILE: $PLAN" || echo "NO_PLAN_FILE"
@@ -2150,7 +2156,8 @@ Output a summary header: `Pre-Landing Review: N issues (X critical, Y informatio
- If 3 or fewer ASK items, you may use individual AskUserQuestion calls instead
7. **After all fixes (auto + user-approved):**
- If ANY fixes were applied: commit fixed files by name (`git add <fixed-files> && git commit -m "fix: pre-landing review fixes"`), then **STOP** and tell the user to run `/ship` again to re-test.
- If ANY fixes were applied: commit fixed files by name (`git add <fixed-files> && git commit -m "fix: pre-landing review fixes"`), then **stay in this invocation and loop**: re-run the test suite (Step 5) on the fixed code, then re-run this review (Step 9 items 2-6) against the updated diff. Repeat until one full pass applies ZERO fixes — tests green and review clean — then continue to Step 12. NEVER stop to tell the user to run `/ship` again; a fix-and-rerun cycle has no user decision in it, and stopping there breaks the fully-automated contract (#2391).
- **Bound: 3 fix cycles.** If the 3rd cycle still applies fixes, STOP and report which findings keep reappearing — a review that won't converge is a genuine blocker worth human eyes, not a re-run request.
- If no fixes applied (all ASK items skipped, or no issues found): continue to Step 12.
8. Output summary: `Pre-Landing Review: N issues — M auto-fixed, K asked (J fixed, L skipped)`
@@ -2296,7 +2303,7 @@ stay agent judgment; the slot pick stays `gstack-next-version`.
```bash
bun run $GSTACK_ROOT/bin/gstack-version-bump write --version "$NEW_VERSION"
```
The CLI validates the 4-digit `MAJOR.MINOR.PATCH.MICRO` pattern and writes **both** VERSION and package.json. On a half-write (VERSION written, package.json failed) it exits 3 — re-run, and classify will report DRIFT_STALE_PKG for `repair` to fix.
The CLI validates the version pattern (4-digit `MAJOR.MINOR.PATCH.MICRO`; 3-digit for repos whose pinned version source uses plain semver) and writes VERSION, the manifest, and the manifest's npm lockfiles (`package-lock.json` / `npm-shrinkwrap.json`) when they already exist — never created. The manifest is resolved as `--package-json-path` → `.gstack/package-json-path` → `./package.json`, so a repo whose only Node package lives in a subdirectory (`web/`, `app/`) is covered by a one-line pin instead of silently getting a VERSION-only bump. npm rejects 4-component versions, so the manifest and lockfiles carry the npm-valid 3-digit translation (`1.67.0.0` → `1.67.0`); VERSION stays the 4-digit source of truth and classify judges drift against the translated form. On a half-write it exits 3 — re-run, and classify will report DRIFT_STALE_PKG for `repair` to fix.
5. **Record the release decision** (durable cross-session memory). The bump level is a real decision the next session should not re-derive blind:
```bash
+36 -17
View File
@@ -100,9 +100,11 @@ else
fi
$GSTACK_BIN/gstack-timeline-log '{"skill":"ship","event":"started","branch":"'"$_BRANCH"'","session":"'"$_SESSION_ID"'"}' 2>/dev/null &
_HAS_ROUTING="no"
if [ -f CLAUDE.md ] && grep -q "## Skill routing" CLAUDE.md 2>/dev/null; then
_HAS_ROUTING="yes"
fi
for _RF in CLAUDE.md AGENTS.md; do
if [ -f "$_RF" ] && grep -q "## Skill routing" "$_RF" 2>/dev/null; then
_HAS_ROUTING="yes"
fi
done
_ROUTING_DECLINED=$($GSTACK_BIN/gstack-config get routing_declined 2>/dev/null || echo "false")
echo "HAS_ROUTING: $_HAS_ROUTING"
echo "ROUTING_DECLINED: $_ROUTING_DECLINED"
@@ -496,10 +498,13 @@ _BRAIN_SYNC_MODE=$("$_BRAIN_CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || e
# Detect remote-MCP mode (Path 4 of /setup-gbrain). Local artifacts sync is
# a no-op in remote mode; the brain server pulls from GitHub/GitLab on its
# own cadence. Read claude.json directly to keep this preamble fast (no
# subprocess to claude CLI on every skill start).
# subprocess to claude CLI on every skill start). Both registration scopes
# are read (#2499): user scope, then the nearest-ancestor project scope.
_GBRAIN_MCP_MODE="none"
_GBRAIN_MCP_ENTRY=""
if command -v jq >/dev/null 2>&1 && [ -f "$HOME/.claude.json" ]; then
_GBRAIN_MCP_TYPE=$(jq -r '.mcpServers.gbrain.type // .mcpServers.gbrain.transport // empty' "$HOME/.claude.json" 2>/dev/null)
_GBRAIN_MCP_ENTRY=$(jq -c --arg cwd "$PWD" '.mcpServers.gbrain // ((.projects // {}) | to_entries | map(select((.key as $k | $cwd == $k or ($cwd | startswith($k + "/"))) and ((try .value.mcpServers.gbrain catch null) != null))) | sort_by(.key | length) | last | .value.mcpServers.gbrain) // empty' "$HOME/.claude.json" 2>/dev/null)
_GBRAIN_MCP_TYPE=$(printf '%s' "$_GBRAIN_MCP_ENTRY" | jq -r '.type // .transport // empty' 2>/dev/null)
case "$_GBRAIN_MCP_TYPE" in
url|http|sse) _GBRAIN_MCP_MODE="remote-http" ;;
stdio) _GBRAIN_MCP_MODE="local-stdio" ;;
@@ -520,6 +525,7 @@ if [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then
_BRAIN_DO_PULL=1
if [ -f "$_BRAIN_LAST_PULL_FILE" ]; then
_BRAIN_LAST=$(cat "$_BRAIN_LAST_PULL_FILE" 2>/dev/null || echo 0)
case "$_BRAIN_LAST" in ''|*[!0-9]*) _BRAIN_LAST=0 ;; esac
_BRAIN_AGE=$(( _BRAIN_NOW - _BRAIN_LAST ))
[ "$_BRAIN_AGE" -lt 86400 ] && _BRAIN_DO_PULL=0
fi
@@ -533,7 +539,7 @@ fi
if [ "$_GBRAIN_MCP_MODE" = "remote-http" ]; then
# Remote-MCP mode: local artifacts sync is a no-op (brain admin's server
# pulls from GitHub/GitLab). Show the user this is by design, not broken.
_GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|')
_GBRAIN_HOST=$(printf '%s' "${_GBRAIN_MCP_ENTRY:-}" | jq -r '.url // empty' 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|' | head -1 | tr -cd 'A-Za-z0-9._-')
echo "ARTIFACTS_SYNC: remote-mode (managed by brain server ${_GBRAIN_HOST:-remote})"
elif [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then
_BRAIN_QUEUE_DEPTH=0
@@ -618,8 +624,8 @@ eval "$($GSTACK_BIN/gstack-slug 2>/dev/null)"
_PROJ="${GSTACK_HOME:-$HOME/.gstack}/projects/${SLUG:-unknown}"
if [ -d "$_PROJ" ]; then
echo "--- RECENT ARTIFACTS ---"
find "$_PROJ/ceo-plans" "$_PROJ/checkpoints" -type f -name "*.md" 2>/dev/null | xargs ls -t 2>/dev/null | head -3
[ -f "$_PROJ/${_BRANCH}-reviews.jsonl" ] && echo "REVIEWS: $(wc -l < "$_PROJ/${_BRANCH}-reviews.jsonl" | tr -d ' ') entries"
find "$_PROJ/ceo-plans" "$_PROJ/checkpoints" -type f -name "*.md" 2>/dev/null | xargs -r ls -t 2>/dev/null | head -3
[ -f "$_PROJ/${BRANCH:-unknown}-reviews.jsonl" ] && echo "REVIEWS: $(wc -l < "$_PROJ/${BRANCH:-unknown}-reviews.jsonl" | tr -d ' ') entries"
[ -f "$_PROJ/timeline.jsonl" ] && tail -5 "$_PROJ/timeline.jsonl"
if [ -f "$_PROJ/timeline.jsonl" ]; then
_LAST=$(grep "\"branch\":\"${_BRANCH}\"" "$_PROJ/timeline.jsonl" 2>/dev/null | grep '"event":"completed"' | tail -1)
@@ -627,7 +633,7 @@ if [ -d "$_PROJ" ]; then
_RECENT_SKILLS=$(grep "\"branch\":\"${_BRANCH}\"" "$_PROJ/timeline.jsonl" 2>/dev/null | grep '"event":"completed"' | tail -3 | grep -o '"skill":"[^"]*"' | sed 's/"skill":"//;s/"//' | tr '\n' ',')
[ -n "$_RECENT_SKILLS" ] && echo "RECENT_PATTERN: $_RECENT_SKILLS"
fi
_LATEST_CP=$(find "$_PROJ/checkpoints" -name "*.md" -type f 2>/dev/null | xargs ls -t 2>/dev/null | head -1)
_LATEST_CP=$(find "$_PROJ/checkpoints" -name "*.md" -type f 2>/dev/null | xargs -r ls -t 2>/dev/null | head -1)
[ -n "$_LATEST_CP" ] && echo "LATEST_CHECKPOINT: $_LATEST_CP"
if [ -f "$_PROJ/decisions.active.json" ]; then
echo "--- ACTIVE DECISIONS (recent, scope-relevant) ---"
@@ -703,7 +709,7 @@ If you are looping on the same diagnostic, same file, or failed fix variants, ST
## Question Tuning (skip entirely if `QUESTION_TUNING: false`)
Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `printf '%s' "<question summary>" | $GSTACK_BIN/gstack-question-preference --check "<id>" --summary-stdin` (piped summary feeds the one-way keyword net, #2024). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
Before each AskUserQuestion, choose `question_id` from `$GSTACK_ROOT/scripts/question-registry.ts` or `{skill}-{slug}`, then run `printf '%s' "<question summary>" | $GSTACK_BIN/gstack-question-preference --check "<id>" --summary-stdin` (piped summary feeds the one-way keyword net, #2024). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask.
**Embed the question_id as a marker in the question text** so hooks can identify it deterministically (plan-tune cathedral T14 / D18 progressive markers). Append `<gstack-qid:{question_id}>` somewhere in the rendered question (the leading line or trailing line is fine; the marker doesn't render visibly to the user when wrapped in HTML-style angle brackets, but the hook strips it). Without the marker the PreToolUse enforcement hook treats the AUQ as observed-only and never auto-decides — so always include it when the question matches a registered `question_id`.
@@ -1709,7 +1715,7 @@ Repo: {owner/repo}
```bash
setopt +o nomatch 2>/dev/null || true # zsh compat
BRANCH=$(git branch --show-current 2>/dev/null | tr '/' '-')
BRANCH=$(git branch --show-current 2>/dev/null | tr '/' '-' | tr -cd 'a-zA-Z0-9._-')
REPO=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)")
# Compute project slug for ~/.gstack/projects/ lookup
_PLAN_SLUG=$(git remote get-url origin 2>/dev/null | sed 's|.*[:/]\([^/]*/[^/]*\)\.git$|\1|;s|.*[:/]\([^/]*/[^/]*\)$|\1|' | tr '/' '-' | tr -cd 'a-zA-Z0-9._-') || true
@@ -1719,7 +1725,7 @@ for PLAN_DIR in "$HOME/.gstack/projects/$_PLAN_SLUG" "$HOME/.claude/plans" "$HOM
[ -d "$PLAN_DIR" ] || continue
PLAN=$(ls -t "$PLAN_DIR"/*.md 2>/dev/null | xargs grep -l "$BRANCH" 2>/dev/null | head -1)
[ -z "$PLAN" ] && PLAN=$(ls -t "$PLAN_DIR"/*.md 2>/dev/null | xargs grep -l "$REPO" 2>/dev/null | head -1)
[ -z "$PLAN" ] && PLAN=$(find "$PLAN_DIR" -name '*.md' -mmin -1440 -maxdepth 1 2>/dev/null | xargs ls -t 2>/dev/null | head -1)
[ -z "$PLAN" ] && PLAN=$(find "$PLAN_DIR" -name '*.md' -mmin -1440 -maxdepth 1 2>/dev/null | xargs -r ls -t 2>/dev/null | head -1)
[ -n "$PLAN" ] && break
done
[ -n "$PLAN" ] && echo "PLAN_FILE: $PLAN" || echo "NO_PLAN_FILE"
@@ -2139,7 +2145,7 @@ If Codex is available, run a lightweight design check on the diff:
```bash
TMPERR_DRL=$(mktemp /tmp/codex-drl-XXXXXXXX)
_REPO_ROOT=$(git rev-parse --show-toplevel) || { echo "ERROR: not in a git repo" >&2; exit 1; }
codex exec "Review the git diff on this branch. Run 7 litmus checks (YES/NO each): 1. Brand/product unmistakable in first screen? 2. One strong visual anchor present? 3. Page understandable by scanning headlines only? 4. Each section has one job? 5. Are cards actually necessary? 6. Does motion improve hierarchy or atmosphere? 7. Would design feel premium with all decorative shadows removed? Flag any hard rejections: 1. Generic SaaS card grid as first impression 2. Beautiful image with weak brand 3. Strong headline with no clear action 4. Busy imagery behind text 5. Sections repeating same mood statement 6. Carousel with no narrative purpose 7. App UI made of stacked cards instead of layout 5 most important design findings only. Reference file:line." -C "$_REPO_ROOT" -s read-only -c 'model_reasoning_effort="high"' --enable web_search_cached < /dev/null 2>"$TMPERR_DRL"
codex exec "Review the git diff on this branch. Run 7 litmus checks (YES/NO each): 1. Brand/product unmistakable in first screen? 2. One strong visual anchor present? 3. Page understandable by scanning headlines only? 4. Each section has one job? 5. Are cards actually necessary? 6. Does motion improve hierarchy or atmosphere? 7. Would design feel premium with all decorative shadows removed? Flag any hard rejections: 1. Generic SaaS card grid as first impression 2. Beautiful image with weak brand 3. Strong headline with no clear action 4. Busy imagery behind text 5. Sections repeating same mood statement 6. Carousel with no narrative purpose 7. App UI made of stacked cards instead of layout 5 most important design findings only. Reference file:line." -C "$_REPO_ROOT" -s read-only -c 'model_reasoning_effort="high"' -c 'web_search="cached"' < /dev/null 2>"$TMPERR_DRL"
```
Use a 5-minute timeout (`timeout: 300000`). After the command completes, read stderr:
@@ -2404,7 +2410,8 @@ Output a summary header: `Pre-Landing Review: N issues (X critical, Y informatio
- If 3 or fewer ASK items, you may use individual AskUserQuestion calls instead
7. **After all fixes (auto + user-approved):**
- If ANY fixes were applied: commit fixed files by name (`git add <fixed-files> && git commit -m "fix: pre-landing review fixes"`), then **STOP** and tell the user to run `/ship` again to re-test.
- If ANY fixes were applied: commit fixed files by name (`git add <fixed-files> && git commit -m "fix: pre-landing review fixes"`), then **stay in this invocation and loop**: re-run the test suite (Step 5) on the fixed code, then re-run this review (Step 9 items 2-6) against the updated diff. Repeat until one full pass applies ZERO fixes — tests green and review clean — then continue to Step 12. NEVER stop to tell the user to run `/ship` again; a fix-and-rerun cycle has no user decision in it, and stopping there breaks the fully-automated contract (#2391).
- **Bound: 3 fix cycles.** If the 3rd cycle still applies fixes, STOP and report which findings keep reappearing — a review that won't converge is a genuine blocker worth human eyes, not a re-run request.
- If no fixes applied (all ASK items skipped, or no issues found): continue to Step 12.
8. Output summary: `Pre-Landing Review: N issues — M auto-fixed, K asked (J fixed, L skipped)`
@@ -2498,10 +2505,20 @@ _CODEX_CFG=$($GSTACK_ROOT/bin/gstack-config get codex_reviews 2>/dev/null || ech
source $GSTACK_ROOT/bin/gstack-codex-probe 2>/dev/null || true
if [ "$_CODEX_CFG" = "disabled" ]; then
_CODEX_MODE="disabled"
# Running-under-Codex presence probe (#2519): a live Codex session exports
# CODEX_THREAD_ID / CODEX_SANDBOX into every shell it spawns (verified
# against a live `codex exec 'env | grep -i codex'` capture, codex 0.147.0).
# Nested codex spawns from inside a Codex host multiply token burn
# (observed: one /review = 15M tokens). GSTACK_FORCE_CODEX_REVIEW=1 forces
# the nested passes anyway.
elif [ "${GSTACK_FORCE_CODEX_REVIEW:-0}" != "1" ] && { [ -n "${CODEX_THREAD_ID:-}" ] || [ -n "${CODEX_SANDBOX:-}" ]; }; then
_CODEX_MODE="under_codex"
elif ! command -v codex >/dev/null 2>&1; then
_CODEX_MODE="not_installed"; _gstack_codex_log_event "codex_cli_missing" 2>/dev/null || true
elif ! _gstack_codex_auth_probe >/dev/null 2>&1; then
_CODEX_MODE="not_authed"; _gstack_codex_log_event "codex_auth_failed" 2>/dev/null || true
elif ! _gstack_codex_model_probe; then
_CODEX_MODE="model_unusable"
else
_CODEX_MODE="ready"; _gstack_codex_version_check 2>/dev/null || true
fi
@@ -2511,7 +2528,9 @@ echo "CODEX_MODE: $_CODEX_MODE"
Branch on the echoed `CODEX_MODE`:
- **`disabled`** — the user turned Codex reviews off (`codex_reviews=disabled`). Skip the Codex passes only; the Claude adversarial subagent below STILL runs (it is free and fast). Print: "Codex passes skipped (codex_reviews disabled) — running Claude adversarial only."
- **`not_installed`** — Codex CLI absent. Print: "Codex not installed — using Claude subagent. Install for cross-model coverage: `npm install -g @openai/codex`." Fall back to the Claude subagent path.
- **`under_codex`** — this session is already running INSIDE a Codex host, so spawning codex again is the same model reviewing itself at multiplied token cost (#2519). Print exactly one line: "[running under Codex — nested codex passes skipped; set GSTACK_FORCE_CODEX_REVIEW=1 to force]" and skip the codex invocations below; run the section's free in-host pass instead if it defines one.
- **`not_authed`** — installed but no credentials. Print: "Codex installed but not authenticated — using Claude subagent. Run `codex login` or set `$CODEX_API_KEY`." Fall back to the Claude subagent path.
- **`model_unusable`** — authed but the account cannot use its configured model (#2477: HTTP 400 on every call, usually a stale `model =` pin in `~/.codex/config.toml`). Relay the probe's HINT lines, tell the user the one-line fix (update the pin; `[notice.model_migrations]` names the replacement), and fall back to the Claude subagent path. The ~10s round trip is cached for 1h; timeouts fail open to `ready`.
- **`ready`** — run the Codex pass below.
For this diff-review path, `CODEX_MODE: disabled` means skip the Codex passes ONLY — the
@@ -2551,7 +2570,7 @@ _REPO_ROOT=$(git rev-parse --show-toplevel) || { echo "ERROR: not in a git repo"
# here. It defines _gstack_codex_timeout_wrapper (gtimeout -> timeout ->
# unwrapped fallback), added in #1056 but never wired into this call site.
source $GSTACK_ROOT/bin/gstack-codex-probe 2>/dev/null || true
_gstack_codex_timeout_wrapper 540 codex exec "IMPORTANT: Do NOT read or execute any files under ~/.claude/, ~/.agents/, .factory/skills/, or agents/. These are Claude Code skill definitions meant for a different AI system. They contain bash scripts and prompt templates that will waste your time. Ignore them completely. Do NOT modify agents/openai.yaml. Stay focused on the repository code only.\n\nReview the changes on this branch against the base branch. Run DIFF_BASE=$(git merge-base origin/<base> HEAD) && git diff "$DIFF_BASE" to see the diff. Your job is to find ways this code will fail in production. Think like an attacker and a chaos engineer. Find edge cases, race conditions, security holes, resource leaks, failure modes, and silent data corruption paths. Be adversarial. Be thorough. No compliments — just the problems. End your output with ONE line in the canonical format `Recommendation: <action> because <one-line reason naming the most exploitable finding>`. Generic reasons like 'because it's safer' do not qualify; the reason must point to a specific finding or no-fix rationale." -C "$_REPO_ROOT" -s read-only -c 'model_reasoning_effort="high"' --enable web_search_cached < /dev/null 2>"$TMPERR_ADV"
_gstack_codex_timeout_wrapper 540 codex exec "IMPORTANT: Do NOT read or execute any files under ~/.claude/, ~/.agents/, .factory/skills/, or agents/. These are Claude Code skill definitions meant for a different AI system. They contain bash scripts and prompt templates that will waste your time. Ignore them completely. Do NOT modify agents/openai.yaml. Stay focused on the repository code only.\n\nReview the changes on this branch against the base branch. Run DIFF_BASE=$(git merge-base origin/<base> HEAD) && git diff "$DIFF_BASE" to see the diff. Your job is to find ways this code will fail in production. Think like an attacker and a chaos engineer. Find edge cases, race conditions, security holes, resource leaks, failure modes, and silent data corruption paths. Be adversarial. Be thorough. No compliments — just the problems. End your output with ONE line in the canonical format `Recommendation: <action> because <one-line reason naming the most exploitable finding>`. Generic reasons like 'because it's safer' do not qualify; the reason must point to a specific finding or no-fix rationale." -C "$_REPO_ROOT" -s read-only -c 'model_reasoning_effort="high"' -c 'web_search="cached"' < /dev/null 2>"$TMPERR_ADV"
```
Set the Bash tool's `timeout` parameter to `600000` (10 minutes). It sits ABOVE the 540s wrapper deliberately, so the wrapper fires first and a stall surfaces as a diagnosable exit 124 instead of a harness kill that returns nothing. The wrapper resolves `gtimeout`, then `timeout`, then runs unwrapped, so it is safe on a macOS without coreutils. After the command completes, read stderr:
@@ -2584,7 +2603,7 @@ cd "$_REPO_ROOT"
# here. It defines _gstack_codex_timeout_wrapper (gtimeout -> timeout ->
# unwrapped fallback), added in #1056 but never wired into this call site.
source $GSTACK_ROOT/bin/gstack-codex-probe 2>/dev/null || true
_gstack_codex_timeout_wrapper 540 codex review --base <base> -c 'model_reasoning_effort="high"' --enable web_search_cached < /dev/null 2>"$TMPERR"
_gstack_codex_timeout_wrapper 540 codex review --base <base> -c 'model_reasoning_effort="high"' -c 'web_search="cached"' < /dev/null 2>"$TMPERR"
```
**No prompt argument.** `--base` is what scopes the review, and the positional `[PROMPT]` is mutually exclusive with it — passing both fails at argv parsing. Do NOT "fix" that error by dropping `--base` and keeping the prompt: a prompt-only `codex review` silently falls back to the **uncommitted working-tree** scope (`git status --short; git diff`), so it reviews the wrong changes and reports "no changes" on a clean tree. Prompt text describing the diff range does not change what the CLI feeds the reviewer. Unlike the adversarial pass above, which uses `codex exec` and really does run the git command it's told to, this path gets a pre-computed diff from the CLI — which is also why it needs no filesystem boundary.
@@ -2712,7 +2731,7 @@ stay agent judgment; the slot pick stays `gstack-next-version`.
```bash
bun run $GSTACK_ROOT/bin/gstack-version-bump write --version "$NEW_VERSION"
```
The CLI validates the 4-digit `MAJOR.MINOR.PATCH.MICRO` pattern and writes **both** VERSION and package.json. On a half-write (VERSION written, package.json failed) it exits 3 — re-run, and classify will report DRIFT_STALE_PKG for `repair` to fix.
The CLI validates the version pattern (4-digit `MAJOR.MINOR.PATCH.MICRO`; 3-digit for repos whose pinned version source uses plain semver) and writes VERSION, the manifest, and the manifest's npm lockfiles (`package-lock.json` / `npm-shrinkwrap.json`) when they already exist — never created. The manifest is resolved as `--package-json-path``.gstack/package-json-path``./package.json`, so a repo whose only Node package lives in a subdirectory (`web/`, `app/`) is covered by a one-line pin instead of silently getting a VERSION-only bump. npm rejects 4-component versions, so the manifest and lockfiles carry the npm-valid 3-digit translation (`1.67.0.0``1.67.0`); VERSION stays the 4-digit source of truth and classify judges drift against the translated form. On a half-write it exits 3 — re-run, and classify will report DRIFT_STALE_PKG for `repair` to fix.
5. **Record the release decision** (durable cross-session memory). The bump level is a real decision the next session should not re-derive blind:
```bash
+7
View File
@@ -33,9 +33,16 @@ let package = Package(
path: "Sources/DebugBridgeTouch",
publicHeadersPath: "include",
cSettings: [
// Explicit, because the source guard depends on it. SwiftPM's
// implicit DEBUG for C-family targets is not something to bet a
// private-API exposure on the two Swift targets already declare
// it, and this target is the one that actually links private API.
.define("DEBUG", .when(configuration: .debug)),
],
linkerSettings: [
// IOKit is loaded dynamically via dlopen at runtime (it's a
// private framework on iOS and can't be linked statically).
// UIKit links normally.
.linkedFramework("UIKit", .when(platforms: [.iOS])),
]
),
@@ -20,12 +20,29 @@
#import "DebugBridgeTouch.h"
#import <TargetConditionals.h>
// DEBUG gate in addition to TARGET_OS_IOS: the private-API touch synthesis must
// compile out of Release builds entirely (App Store rejection risk + no
// automation code in shipped binaries). DEBUG is defined for this target only
// in the debug configuration (see cSettings in Package.swift), so a Release iOS
// build emits an empty translation unit zero private symbols.
#if TARGET_OS_IOS && DEBUG
#if !defined(DEBUG)
// RELEASE BUILD: this file deliberately emits NOTHING no class, no symbols, no
// private-API references. The header still declares the interface, which is inert
// on its own; every consumer of DebugBridgeTouch is itself `#if DEBUG` guarded, so
// nothing references it here and nothing fails to link.
//
// This guard was added because the comments at the top of this file and in the
// header both PROMISED "DEBUG-only; never shipped to App Store" and "never link in
// Release" — and nothing enforced it. Only `#if TARGET_OS_IOS` was tested, so a
// Release build for iOS compiled the whole implementation in. Measured on a real
// app (Seize Day, 2026-08-15), `nm -j` on a Release binary returned 15 DebugBridge
// symbols including +[DebugBridgeTouch sendTapAtPoint:inWindow:], plus
// IOHIDEventCreateDigitizer, AXSSetAutomationEnabled and IOKit.framework strings.
// That is a Guideline 2.5.1 private-API exposure in a shippable binary.
//
// Package.swift's stated guard `.when(configuration: .debug)` on the consuming
// target's dependency only exists for SwiftPM consumers. An app integrating this
// as a local package inside an .xcodeproj cannot express it: Xcode's Filters column
// in Frameworks/Libraries offers platform conditions only, never configuration. So
// the guard has to live here, in the source, where it holds for every consumer.
#elif TARGET_OS_IOS
#import <UIKit/UIKit.h>
#import <objc/runtime.h>
@@ -345,4 +362,4 @@ static id DBT_HitTestView(UIWindow *window, CGPoint point) {
}
@end
#endif // TARGET_OS_IOS
#endif // !DEBUG / TARGET_OS_IOS
@@ -60,12 +60,13 @@ private func debugBridgeAccessibilityChildren(of element: NSObject) -> [NSObject
@MainActor
enum ScreenshotBridgeImpl {
/// Capture a PNG of the active window. Uses UIGraphicsImageRenderer
/// Capture a PNG of the visible UI. Uses UIGraphicsImageRenderer
/// (modern API, replaces UIGraphicsBeginImageContext). Returns nil if
/// no key window is available (e.g., app backgrounded).
/// no window is available (e.g., app backgrounded).
static func capturePNG() -> Data? {
guard let scene = activeScene(), let window = activeKeyWindow(in: scene) else { return nil }
let bounds = window.bounds
guard let scene = activeScene() else { return nil }
let windows = orderedWindows(in: scene)
guard let bounds = windows.first?.bounds else { return nil }
let format = UIGraphicsImageRendererFormat.default()
// /tap consumes UIKit window points. Render at 1x so screenshot pixels
// use that same coordinate space on 2x/3x devices.
@@ -75,24 +76,73 @@ enum ScreenshotBridgeImpl {
// drawHierarchy is the documented way to snapshot real UIKit
// layers including layer-backed views. afterScreenUpdates: false
// because we want the CURRENT visible state, not a forced layout.
window.drawHierarchy(in: bounds, afterScreenUpdates: false)
//
// Back-to-front across every window: a UIMenu, alert or action
// sheet lives in its OWN window, so drawing only the key window
// silently drops it from the screenshot.
for window in windows.reversed() {
window.drawHierarchy(in: window.bounds, afterScreenUpdates: false)
}
}
return image.pngData()
}
private static func activeScene() -> UIWindowScene? {
static func activeScene() -> UIWindowScene? {
UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.first { $0.activationState == .foregroundActive }
?? (UIApplication.shared.connectedScenes.first as? UIWindowScene)
}
private static func activeKeyWindow(in scene: UIWindowScene) -> UIWindow? {
static func activeKeyWindow(in scene: UIWindowScene) -> UIWindow? {
let windows = scene.windows.filter { window in
!window.isHidden && !String(describing: type(of: window)).contains("PassThroughWindow")
}
return windows.first(where: { $0.isKeyWindow }) ?? windows.max(by: { $0.windowLevel < $1.windowLevel })
}
/// Visible windows, front-most first: higher `windowLevel` wins, and within
/// a level the later window sits on top. Menus, alerts and action sheets
/// get their own window, so `isKeyWindow` alone does not find them.
/// PassThroughWindow overlays stay filtered (same rule as activeKeyWindow).
static func orderedWindows(in scene: UIWindowScene) -> [UIWindow] {
scene.windows
.filter {
!$0.isHidden && $0.alpha > 0.01 && !$0.bounds.isEmpty
&& !String(describing: type(of: $0)).contains("PassThroughWindow")
}
.enumerated()
.sorted {
($0.element.windowLevel.rawValue, Double($0.offset))
> ($1.element.windowLevel.rawValue, Double($1.offset))
}
.map(\.element)
}
/// The window the user is actually touching front-most, not merely key.
static func frontmostWindow() -> UIWindow? {
guard let scene = activeScene() else { return nil }
return orderedWindows(in: scene).first ?? activeKeyWindow(in: scene)
}
/// Roots to search, front-most first: for each window, the top-most
/// presented view controller's view before the window itself.
///
/// Tree order is NOT front-most order. A presented sheet sits *after* the
/// screen it covers in `window.subviews`, so a walk rooted at the window
/// emits the covered screen first and a client taking the first match for a
/// label gets a control the user cannot reach.
static func searchRoots() -> [UIView] {
guard let scene = activeScene() else { return [] }
var roots: [UIView] = []
for window in orderedWindows(in: scene) {
var controller = window.rootViewController
while let presented = controller?.presentedViewController { controller = presented }
if let view = controller?.view, view !== window { roots.append(view) }
roots.append(window)
}
return roots
}
}
// MARK: - ElementsBridge implementation
@@ -103,19 +153,26 @@ enum ElementsBridgeImpl {
/// Each entry has frame (in window coords), accessibility label,
/// identifier, traits as a bitmask, and a parent path. Skips
/// non-accessible / hidden views.
/// Front-most content first, so a client taking the first match for a
/// label gets the element the user can actually reach a presented view
/// is also reachable through its window, so the roots overlap by design
/// and the shared visited set emits each view once, at its front-most
/// position.
static func snapshot() -> [JSONDict] {
guard let scene = activeScene(), let window = activeKeyWindow(in: scene) else { return [] }
var elements: [JSONDict] = []
var visited = Set<ObjectIdentifier>()
var remaining = 2_048
collect(
view: window,
parentPath: "",
window: window,
visited: &visited,
remaining: &remaining,
into: &elements
)
for root in ScreenshotBridgeImpl.searchRoots() {
guard let window = (root as? UIWindow) ?? root.window else { continue }
collect(
view: root,
parentPath: "",
window: window,
visited: &visited,
remaining: &remaining,
into: &elements
)
}
return elements
}
@@ -263,20 +320,6 @@ enum ElementsBridgeImpl {
}
}
}
private static func activeScene() -> UIWindowScene? {
UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.first { $0.activationState == .foregroundActive }
?? (UIApplication.shared.connectedScenes.first as? UIWindowScene)
}
private static func activeKeyWindow(in scene: UIWindowScene) -> UIWindow? {
let windows = scene.windows.filter { window in
!window.isHidden && !String(describing: type(of: window)).contains("PassThroughWindow")
}
return windows.first(where: { $0.isKeyWindow }) ?? windows.max(by: { $0.windowLevel < $1.windowLevel })
}
}
// MARK: - MutationBridge implementation
@@ -301,7 +344,9 @@ enum MutationBridgeImpl {
guard let x = payload["x"] as? NSNumber,
let y = payload["y"] as? NSNumber else { return false }
let point = CGPoint(x: x.doubleValue, y: y.doubleValue)
guard let scene = activeScene(), let window = activeKeyWindow(in: scene) else { return false }
// Front-most, not merely key: a menu/alert/sheet lives in its own
// window, and a tap aimed there must not land on the covered screen.
guard let window = ScreenshotBridgeImpl.frontmostWindow() else { return false }
if let element = findActivatableAXElement(at: point, in: window),
element.accessibilityActivate() {
return true
@@ -353,8 +398,10 @@ enum MutationBridgeImpl {
/// Set text on the first responder if it's a UITextField or UITextView.
private static func handleType(_ payload: JSONDict) -> Bool {
guard let text = payload["text"] as? String else { return false }
guard let scene = activeScene(), let window = activeKeyWindow(in: scene) else { return false }
guard let responder = findFirstResponder(in: window) else { return false }
// Search front-most roots first: the focused field in a presented
// sheet wins over a same-named field on the covered screen.
guard let responder = ScreenshotBridgeImpl.searchRoots()
.lazy.compactMap({ findFirstResponder(in: $0) }).first else { return false }
if let field = responder as? UITextField {
field.text = text
field.sendActions(for: .editingChanged)
@@ -379,8 +426,10 @@ enum MutationBridgeImpl {
let from = CGPoint(x: fx.doubleValue, y: fy.doubleValue)
let to = CGPoint(x: tx.doubleValue, y: ty.doubleValue)
guard let scene = activeScene(), let window = activeKeyWindow(in: scene) else { return false }
guard let hit = window.hitTest(from, with: nil) else { return false }
// Hit-test front-most roots first (sheet before covered screen).
guard let hit = ScreenshotBridgeImpl.searchRoots()
.lazy.compactMap({ $0.hitTest($0.convert(from, from: nil), with: nil) })
.first else { return false }
// Find the nearest enclosing UIScrollView.
var node: UIView? = hit
@@ -420,20 +469,6 @@ enum MutationBridgeImpl {
}
return nil
}
private static func activeScene() -> UIWindowScene? {
UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.first { $0.activationState == .foregroundActive }
?? (UIApplication.shared.connectedScenes.first as? UIWindowScene)
}
private static func activeKeyWindow(in scene: UIWindowScene) -> UIWindow? {
let windows = scene.windows.filter { window in
!window.isHidden && !String(describing: type(of: window)).contains("PassThroughWindow")
}
return windows.first(where: { $0.isKeyWindow }) ?? windows.max(by: { $0.windowLevel < $1.windowLevel })
}
}
#endif // DEBUG && canImport(UIKit)
+27
View File
@@ -157,6 +157,33 @@ describe('gbrain detection override → gen-skill-docs', () => {
}
});
test('with status "engine-locked" (PGLite single-writer, #2456), brain blocks render like "ok"', () => {
const { tmpHome, cleanup } = makeFixture(
JSON.stringify({
gbrain_local_status: 'engine-locked',
gbrain_on_path: true,
gbrain_version: 'test-0.42.26',
}),
);
try {
const snap = regenAndSnapshot({
respectDetection: true,
tmpHome,
files: PROBE_FILES,
});
const content = probeUnion(snap);
// PGLite is single-writer: a live `gbrain serve` (the recommended
// /setup-gbrain default spawns one at session start) legitimately owns
// the embedded DB. gbrain is installed and healthy — a transient lock
// must not silently strip brain blocks (same reasoning as "timeout").
expect(content).toContain('## Save Results to Brain');
expect(content).toContain('gbrain put "office-hours/');
} finally {
cleanup();
}
});
test('with detected:false (status != "ok"), brain blocks stay suppressed', () => {
const { tmpHome, cleanup } = makeFixture(
JSON.stringify({ gbrain_local_status: 'no-cli', gbrain_on_path: false, gbrain_version: null }),
+32 -5
View File
@@ -160,15 +160,31 @@ describe("CLI gate wiring (dry-run subprocess — never spawns a real dream)", (
// Canned `gbrain dream` cycle logs (verbatim shapes observed against a real
// 0.41.x brain). These let us test the post-flight guard WITHOUT a real cycle.
const LOG = {
// Pack lacks the code-symbol phase: extract_atoms is undeclared AND the edge
// resolver matches nothing. Both signals present — pack message must win.
notCodeAware:
// #2341: the DEFAULT base packs legitimately skip the CONTENT phases
// (extract_atoms, synthesize_concepts) while resolve_symbol_edges still runs
// — gbrain's only emitters of "does not declare this phase" are those
// content phases, so the bare-phrase match fired on EVERY base-pack brain
// and told users to churn schema packs for nothing. This shape (content
// phase undeclared, resolver ran, resolved 0) must classify as the 0-edge
// outcome, not the pack-capability one.
basePackZeroEdges:
"[cycle.extract] done\n" +
" - extract_atoms extract_atoms: active pack does not declare this phase\n" +
"[cycle.resolve_symbol_edges] start\n" +
"[cycle.resolve_symbol_edges] done\n" +
" ✓ resolve_symbol_edges 3864 chunk(s) walked; resolved 0, ambiguous 0, unmatched 0\n" +
" totals: extracted=0 embedded=1\n",
// #2341 headline shape: base pack skips content phases AND the graph built
// fine — a healthy run that used to WARN.
basePackBuiltEdges:
" - extract_atoms extract_atoms: active pack does not declare this phase\n" +
" ✓ resolve_symbol_edges 6001 chunk(s) walked; resolved 42, ambiguous 0, unmatched 0\n" +
" - synthesize_concepts synthesize_concepts: active pack does not declare this phase\n",
// The GRAPH phase itself is undeclared: the one shape where the
// pack-capability WARN is the right diagnosis.
graphPhaseUndeclared:
" - resolve_symbol_edges resolve_symbol_edges: active pack does not declare this phase\n" +
" totals: extracted=0 embedded=1\n",
// Embed phase failed for a missing key (isolated: no pack-capability line).
embedFailed:
"[cycle.embed] start\n" +
@@ -202,8 +218,19 @@ describe("parseResolvedEdges", () => {
});
describe("classifyDreamOutcome — post-flight truth guard", () => {
it("flags a non-code-aware schema pack (wins over the 0-edge signal)", () => {
const w = classifyDreamOutcome(LOG.notCodeAware);
it("base-pack content-phase skips classify as 0-edge, NOT pack-capability (#2341)", () => {
const w = classifyDreamOutcome(LOG.basePackZeroEdges);
expect(w).not.toBeNull();
expect(w).toContain("resolved 0");
expect(w).not.toContain("code-aware");
});
it("a healthy base-pack run with a built graph is clean (#2341 headline)", () => {
expect(classifyDreamOutcome(LOG.basePackBuiltEdges)).toBeNull();
});
it("flags pack capability only when the GRAPH phase itself is undeclared", () => {
const w = classifyDreamOutcome(LOG.graphPhaseUndeclared);
expect(w).not.toBeNull();
expect(w).toContain("schema pack");
expect(w).toContain("code-aware");
+159 -5
View File
@@ -66,6 +66,8 @@ function makeEnv(opts: {
withConfig?: boolean;
/** #2051: config carries gbrain's remote_mcp thin-client marker. */
thinClientConfig?: boolean;
/** #2520: content for ~/.claude.json (host MCP registrations). */
claudeJson?: object;
}): FakeEnv {
const tmp = mkdtempSync(join(tmpdir(), "gbrain-local-status-test-"));
const bindir = join(tmp, "bin");
@@ -99,6 +101,10 @@ function makeEnv(opts: {
chmodSync(gbrainPath, 0o755);
}
if (opts.claudeJson) {
writeFileSync(join(home, ".claude.json"), JSON.stringify(opts.claudeJson));
}
return {
tmp,
bindir,
@@ -263,20 +269,46 @@ describe("lib/gbrain-local-status — status classification", () => {
expect(localEngineStatus({ noCache: true })).toBe("timeout");
});
it("honors GBRAIN_HOME for config detection (codex D11)", () => {
// Config lives ONLY at the alternate GBRAIN_HOME; ~/.gbrain has none.
it("honors GBRAIN_HOME for config detection (codex D11) with gbrain's parent-dir semantics (#2521)", () => {
// Config lives ONLY under the alternate GBRAIN_HOME; ~/.gbrain has none.
// gbrain's configDir() treats GBRAIN_HOME as a PARENT dir and appends
// `.gbrain` itself: GBRAIN_HOME=/x → /x/.gbrain/config.json.
env = makeEnv({ withGbrain: true, gbrainBehavior: "ok", withConfig: false });
restoreEnv = applyEnv(env);
const altHome = join(env.tmp, "alt-gbrain");
mkdirSync(join(altHome, ".gbrain"), { recursive: true });
writeFileSync(
join(altHome, ".gbrain", "config.json"),
JSON.stringify({ engine: "pglite", database_url: "pglite:///fake" }),
);
// Without GBRAIN_HOME: misclassified as missing-config.
expect(localEngineStatus({ noCache: true })).toBe("missing-config");
// With GBRAIN_HOME: the relocated config is found at $GBRAIN_HOME/.gbrain.
process.env.GBRAIN_HOME = altHome;
expect(localEngineStatus({ noCache: true })).toBe("ok");
});
it("does NOT read $GBRAIN_HOME/config.json directly — gbrain never reads that file (#2521)", () => {
// A config placed at gstack's OLD (wrong) resolution must be invisible:
// gbrain itself would report "No brain configured" for this layout, so
// gstack classifying "ok" from it is the #2521 split-brain.
env = makeEnv({ withGbrain: true, gbrainBehavior: "ok", withConfig: false });
restoreEnv = applyEnv(env);
const altHome = join(env.tmp, "alt-gbrain-flat");
mkdirSync(altHome, { recursive: true });
writeFileSync(
join(altHome, "config.json"),
JSON.stringify({ engine: "pglite", database_url: "pglite:///fake" }),
);
// Without GBRAIN_HOME: misclassified as missing-config.
expect(localEngineStatus({ noCache: true })).toBe("missing-config");
// With GBRAIN_HOME: the relocated config is found.
process.env.GBRAIN_HOME = altHome;
expect(localEngineStatus({ noCache: true })).toBe("missing-config");
});
it("with GBRAIN_HOME unset, config resolution stays at ~/.gbrain (#2521 unset half)", () => {
env = makeEnv({ withGbrain: true, gbrainBehavior: "ok", withConfig: true });
restoreEnv = applyEnv(env);
// applyEnv deletes GBRAIN_HOME; config was written at $HOME/.gbrain.
expect(process.env.GBRAIN_HOME).toBeUndefined();
expect(localEngineStatus({ noCache: true })).toBe("ok");
});
});
@@ -526,3 +558,125 @@ describe("lib/gbrain-local-status — thin-client (#2051)", () => {
expect(r.status).toBe(1);
});
});
// ---------------------------------------------------------------------------
// #2520: bearer-token thin clients (`gbrain connect --token`) — no remote_mcp
// marker in config.json; the evidence is the host's remote-HTTP MCP
// registration in ~/.claude.json.
// ---------------------------------------------------------------------------
describe("lib/gbrain-local-status — bearer-token thin-client (#2520)", () => {
let env: FakeEnv | null = null;
let restoreEnv: (() => void) | null = null;
afterEach(() => {
if (restoreEnv) restoreEnv();
if (env) env.cleanup();
env = null;
restoreEnv = null;
});
const REMOTE_GBRAIN = {
type: "http",
url: "https://brain.example.com/mcp",
headers: { Authorization: "Bearer test-token" },
};
const LOCAL_GBRAIN = { type: "stdio", command: "gbrain", args: ["serve"] };
it("returns 'thin-client' when config.json is absent but a remote-HTTP gbrain MCP is registered (user scope)", () => {
env = makeEnv({
withGbrain: true,
gbrainBehavior: "ok",
withConfig: false,
claudeJson: { mcpServers: { gbrain: REMOTE_GBRAIN } },
});
restoreEnv = applyEnv(env);
expect(localEngineStatus({ noCache: true })).toBe("thin-client");
});
it("returns 'thin-client' when config.json is absent and the registration is PROJECT-scoped (#2499)", () => {
env = makeEnv({
withGbrain: true,
gbrainBehavior: "ok",
withConfig: false,
claudeJson: {
projects: { "/some/repo": { mcpServers: { "gbrain-remote": REMOTE_GBRAIN } } },
},
});
restoreEnv = applyEnv(env);
expect(localEngineStatus({ noCache: true })).toBe("thin-client");
});
it("reclassifies a failed local probe (engine-locked) as 'thin-client' when the only gbrain MCP is remote", () => {
// The reporter's exact shape: leftover local pglite config, dead/absent
// local engine (probe exits 124 "connect timed out"), brain fully working
// over remote-HTTP MCP with a bearer token.
env = makeEnv({
withGbrain: true,
gbrainBehavior: "engine-locked",
withConfig: true,
claudeJson: { mcpServers: { gbrain: REMOTE_GBRAIN } },
});
restoreEnv = applyEnv(env);
expect(localEngineStatus({ noCache: true })).toBe("thin-client");
});
it("reclassifies broken-db as 'thin-client' when the only gbrain MCP is remote", () => {
env = makeEnv({
withGbrain: true,
gbrainBehavior: "broken-db",
withConfig: true,
claudeJson: { mcpServers: { gbrain: REMOTE_GBRAIN } },
});
restoreEnv = applyEnv(env);
expect(localEngineStatus({ noCache: true })).toBe("thin-client");
});
it("preserves 'engine-locked' when a local-stdio gbrain MCP is ALSO registered (federation guard)", () => {
// A local-stdio registration means the user runs a local engine —
// local-engine statuses must keep their precise meaning, even if a
// second (e.g. team) brain is registered remote-HTTP.
env = makeEnv({
withGbrain: true,
gbrainBehavior: "engine-locked",
withConfig: true,
claudeJson: {
mcpServers: { gbrain: LOCAL_GBRAIN, "gbrain-work": REMOTE_GBRAIN },
},
});
restoreEnv = applyEnv(env);
expect(localEngineStatus({ noCache: true })).toBe("engine-locked");
});
it("still returns 'missing-config' when no gbrain MCP registration exists (discriminator)", () => {
env = makeEnv({
withGbrain: true,
gbrainBehavior: "ok",
withConfig: false,
claudeJson: { mcpServers: { "other-server": { type: "http", url: "https://x.example/mcp" } } },
});
restoreEnv = applyEnv(env);
expect(localEngineStatus({ noCache: true })).toBe("missing-config");
});
it("--is-ok exits 0 on a bearer thin-client fixture (end-to-end gate)", () => {
env = makeEnv({
withGbrain: true,
gbrainBehavior: "engine-locked",
withConfig: true,
claudeJson: { mcpServers: { gbrain: REMOTE_GBRAIN } },
});
const detectBin = join(import.meta.dir, "..", "bin", "gstack-gbrain-detect");
const bunDir = dirname(process.execPath);
const r = spawnSync(detectBin, ["--is-ok"], {
encoding: "utf-8",
env: {
HOME: env.home,
PATH: `${env.bindir}:${bunDir}:/usr/bin:/bin`,
GSTACK_HOME: env.gstackHome,
GSTACK_DETECT_NO_CACHE: "1",
},
});
expect(r.status).toBe(0);
});
});
+9 -4
View File
@@ -44,13 +44,18 @@ describe('gstack-config gbrain-refresh: machine-wide render guards', () => {
expect(branch).toContain('command -v bun');
});
test('renders the :user variant in place into the install', () => {
test('renders the :user variant for the claude host', () => {
expect(branch).toContain('gen:skill-docs:user --host claude');
});
test('is self-documenting about the reset --hard / re-run cycle', () => {
expect(branch).toContain('reset --hard');
expect(branch).toContain('gbrain-refresh');
// #2569: the render goes to an UNTRACKED out-dir, never in place — the old
// in-place render dirtied the install checkout on every refresh, and this
// branch used to self-document a reset --hard / re-run cycle as the
// workaround. The out-dir render makes that cycle unnecessary.
test('renders to an untracked out-dir, never in place (#2569)', () => {
expect(branch).toContain('--out-dir');
expect(branch).toContain('render/claude');
expect(branch).not.toContain('reset --hard');
});
});
+141 -20
View File
@@ -2,6 +2,8 @@ import { describe, test, expect } from "bun:test";
import * as fs from "fs";
import * as path from "path";
import { bashScriptInvocation, gbrainInvocation, windowsShellQuote } from "../lib/gbrain-exec";
const ROOT = path.resolve(import.meta.dir, "..");
const read = (rel: string) => fs.readFileSync(path.join(ROOT, rel), "utf-8");
@@ -16,30 +18,149 @@ describe("#1731 gbrain spawns carry the Windows shell flag", () => {
expect(src).toMatch(/export const NEEDS_SHELL_ON_WINDOWS\s*=\s*process\.platform === "win32"/);
});
// Every direct `gbrain` child spawn in these files must be matched by a
// shell:NEEDS_SHELL_ON_WINDOWS flag. Count openers vs flags as a cheap,
// refactor-resistant invariant.
const gbrainSpawnFiles = [
"lib/gbrain-exec.ts",
"lib/gbrain-sources.ts",
"lib/gbrain-local-status.ts",
];
for (const rel of gbrainSpawnFiles) {
test(`${rel}: every gbrain spawn has shell:NEEDS_SHELL_ON_WINDOWS`, () => {
// #2471 upgraded the #1731 invariant for the seamed files: gbrain spawns
// there must build their (cmd, argv, shell) triple via gbrainInvocation()
// (which owns BOTH the shell flag and cmd.exe quoting), so a direct
// `spawn*("gbrain"` opener is itself the violation.
const seamedFiles = ["lib/gbrain-exec.ts", "lib/gbrain-sources.ts"];
for (const rel of seamedFiles) {
test(`${rel}: gbrain spawns route through gbrainInvocation (no direct openers)`, () => {
const src = read(rel);
const spawnOpeners = src.match(/(spawnSync|spawn|execFileSync)\("gbrain"/g)?.length ?? 0;
const shellFlags = src.match(/shell:\s*NEEDS_SHELL_ON_WINDOWS/g)?.length ?? 0;
expect(spawnOpeners).toBeGreaterThan(0);
expect(shellFlags).toBeGreaterThanOrEqual(spawnOpeners);
const directOpeners = src.match(/(spawnSync|spawn|execFileSync)\(\s*["']gbrain["']/g)?.length ?? 0;
expect(directOpeners).toBe(0);
expect(src).toContain("gbrainInvocation(");
});
}
test("orchestrator brain-sync spawns carry the Windows shell flag", () => {
// Not-yet-seamed file: every direct gbrain spawn must still carry the
// #1731 shell flag. (Migrate to gbrainInvocation when next touched.)
test("lib/gbrain-local-status.ts: every gbrain spawn has shell:NEEDS_SHELL_ON_WINDOWS", () => {
const src = read("lib/gbrain-local-status.ts");
const spawnOpeners = src.match(/(spawnSync|spawn|execFileSync)\("gbrain"/g)?.length ?? 0;
const shellFlags = src.match(/shell:\s*NEEDS_SHELL_ON_WINDOWS/g)?.length ?? 0;
expect(spawnOpeners).toBeGreaterThan(0);
expect(shellFlags).toBeGreaterThanOrEqual(spawnOpeners);
});
// NOT the brain-sync script. `shell: true` is right for the gbrain.cmd shim
// and wrong for a bash shebang script: cmd.exe resolves .cmd/.bat via PATHEXT
// and has no concept of a shebang, so gstack-brain-sync came back as "is not
// recognized as an internal or external command" on EVERY Windows run. It
// needs an interpreter, not a shell — see bashScriptInvocation.
test("orchestrator invokes brain-sync through bash, never a raw spawn", () => {
const src = read("bin/gstack-gbrain-sync.ts");
const brainSyncSpawns = src.match(/spawnSync\(brainSyncPath,/g)?.length ?? 0;
expect(brainSyncSpawns).toBe(2);
// Both spawnSync(brainSyncPath, ...) blocks must include the shell flag.
const withShell = src.match(/spawnSync\(brainSyncPath,[\s\S]*?shell:\s*NEEDS_SHELL_ON_WINDOWS/g)?.length ?? 0;
expect(withShell).toBe(2);
expect(src).toMatch(/bashScriptInvocation\(brainSyncPath, \["--discover-new"\]\)/);
expect(src).toMatch(/bashScriptInvocation\(brainSyncPath, \["--once"\]\)/);
// The old shape must not come back: it fails silently-ish on Windows.
expect(src).not.toMatch(/spawnSync\(brainSyncPath,/);
expect(src).not.toMatch(/spawnSync\(brainSyncPath,[\s\S]*?shell:\s*NEEDS_SHELL_ON_WINDOWS/);
});
});
describe("bashScriptInvocation", () => {
const WIN_BASH = "C:\\Program Files\\Git\\bin\\bash.exe";
test("POSIX execs the script directly, no interpreter needed", () => {
const inv = bashScriptInvocation("/home/u/.claude/skills/gstack/bin/gstack-brain-sync", ["--once"], {
platform: "linux",
});
expect(inv).toEqual({
cmd: "/home/u/.claude/skills/gstack/bin/gstack-brain-sync",
argv: ["--once"],
shell: false,
});
});
test("Windows routes through Git bash with the script as argv[0]", () => {
const inv = bashScriptInvocation("C:\\Users\\u\\.claude\\skills\\gstack\\bin\\gstack-brain-sync", ["--once"], {
platform: "win32",
exists: (p) => p === WIN_BASH,
env: {},
});
expect(inv?.cmd).toBe(WIN_BASH);
expect(inv?.argv[1]).toBe("--once");
});
test("Windows forward-slashes the script path", () => {
// bash treats backslashes as escapes, so a verbatim Windows path loses its
// separators and the script is never found.
const inv = bashScriptInvocation("C:\\Users\\u\\bin\\gstack-brain-sync", [], {
platform: "win32",
exists: (p) => p === WIN_BASH,
env: {},
});
expect(inv?.argv[0]).toBe("C:/Users/u/bin/gstack-brain-sync");
expect(inv?.argv[0]).not.toContain("\\");
});
test("never asks for a shell — cmd.exe is what broke this", () => {
const inv = bashScriptInvocation("C:\\x\\gstack-brain-sync", [], {
platform: "win32",
exists: (p) => p === WIN_BASH,
env: {},
});
expect(inv?.shell).toBe(false);
});
test("GSTACK_BASH overrides the search for unusual installs", () => {
const custom = "D:\\tools\\git\\bin\\bash.exe";
const inv = bashScriptInvocation("C:\\x\\gstack-brain-sync", [], {
platform: "win32",
exists: (p) => p === custom || p === WIN_BASH,
env: { GSTACK_BASH: custom },
});
expect(inv?.cmd).toBe(custom);
});
test("returns null when Windows has no bash, so the caller can say why", () => {
const inv = bashScriptInvocation("C:\\x\\gstack-brain-sync", [], {
platform: "win32",
exists: () => false,
env: {},
});
expect(inv).toBeNull();
});
});
// #2471: with `shell: true` on Windows, node/bun JOIN argv into one cmd.exe
// string without quoting — a path with a space (`C:\Users\First Last\repo`)
// splits into two arguments and `gbrain sources add --path` targets the wrong
// directory. The invocation seam quotes every risky argument exactly once.
describe("#2471 gbrain invocation seam quotes for cmd.exe", () => {
test("safe charset passes through untouched", () => {
expect(windowsShellQuote("sources")).toBe("sources");
expect(windowsShellQuote("--json")).toBe("--json");
expect(windowsShellQuote("C:\\Users\\j\\repo")).toBe("C:\\Users\\j\\repo");
});
test("a path with a space is double-quoted", () => {
expect(windowsShellQuote("C:\\Users\\First Last\\repo")).toBe('"C:\\Users\\First Last\\repo"');
});
test("embedded quotes are doubled (cmd.exe escape)", () => {
expect(windowsShellQuote('we"ird')).toBe('"we""ird"');
});
test("empty argument stays a quoted empty string, not vanishing", () => {
expect(windowsShellQuote("")).toBe('""');
});
test("shell metacharacters are wrapped so cmd.exe cannot interpret them", () => {
for (const bad of ["a b", "a&b", "a|b", "a>b", "a<b", "a^b", "a(b)", "a;b"]) {
expect(windowsShellQuote(bad).startsWith('"')).toBe(true);
}
});
test("gbrainInvocation on POSIX is a passthrough with shell:false", () => {
if (process.platform === "win32") return; // the win32 half is the map+quote path above
const inv = gbrainInvocation(["sources", "add", "id", "--path", "/a dir/with space"]);
expect(inv).toEqual({ cmd: "gbrain", argv: ["sources", "add", "id", "--path", "/a dir/with space"], shell: false });
});
test("no direct un-seamed gbrain spawn remains in gbrain-sources.ts", () => {
const src = read("lib/gbrain-sources.ts");
expect(src).not.toMatch(/spawnSync\(\s*["']gbrain["']/);
expect(src).not.toMatch(/execFileSync\(\s*["']gbrain["']/);
expect(src).toContain("gbrainInvocation(");
});
});
+11
View File
@@ -20,11 +20,17 @@ describe('gen-skill-docs --out-dir (B2 render isolation)', () => {
return createHash('sha256').update(fs.readFileSync(p)).digest('hex');
}
function porcelain(): string {
const r = spawnSync('git', ['status', '--porcelain'], { cwd: ROOT, encoding: 'utf-8' });
return r.status === 0 ? r.stdout : '';
}
test('renders :user to out-dir, rewrites section paths, leaves worktree canonical', () => {
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-home-'));
const outDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-out-'));
const worktreeSkill = path.join(ROOT, 'ship', 'SKILL.md');
const beforeHash = hashFile(worktreeSkill);
const beforePorcelain = porcelain();
try {
// Force gbrain detection ON for --respect-detection.
fs.writeFileSync(
@@ -47,6 +53,11 @@ describe('gen-skill-docs --out-dir (B2 render isolation)', () => {
// (a) worktree byte-unchanged
expect(hashFile(worktreeSkill)).toBe(beforeHash);
// (a2, #2569) the render adds ZERO new dirt to the source checkout —
// compared before/after rather than asserting empty, so a dev's own
// unrelated dirty files can't false-fail the suite.
expect(porcelain()).toBe(beforePorcelain);
// (b) inline block present in the rendered SKILL.md
expect(skillContent).toContain('Brain Context Load');
+230 -13
View File
@@ -1,4 +1,5 @@
import { describe, test, expect, beforeAll } from 'bun:test';
import { assertSinglePreamble } from '../scripts/gen-skill-docs';
import { COMMAND_DESCRIPTIONS } from '../browse/src/commands';
import { SNAPSHOT_FLAGS } from '../browse/src/snapshot';
import * as fs from 'fs';
@@ -257,10 +258,16 @@ describe('gen-skill-docs', () => {
expect(violations).toEqual([]);
});
test('package.json version matches VERSION file', () => {
test('package.json version matches VERSION file (npm-valid translation)', () => {
// Decision 11 (v1.67 wave): VERSION stays the 4-digit source of truth;
// package.json carries the npm-valid 3-digit translation (npm rejects a
// fourth component). The pre-v1.67 1:1 four-digit mirror is also accepted
// (grandfathered until the next write), matching gstack-version-bump's
// own drift contract.
const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf-8'));
const version = fs.readFileSync(path.join(ROOT, 'VERSION'), 'utf-8').trim();
expect(pkg.version).toBe(version);
const npmTranslation = version.split('.').slice(0, 3).join('.');
expect([npmTranslation, version]).toContain(pkg.version);
});
test('generated files are fresh (match --dry-run)', () => {
@@ -1507,6 +1514,25 @@ describe('CHANGELOG_WORKFLOW resolver', () => {
});
});
// --- Duplicate {{PREAMBLE}} guard (#2508/#2362) ---
describe('assertSinglePreamble', () => {
test('one {{PREAMBLE}} passes', () => {
expect(() => assertSinglePreamble('a\n{{PREAMBLE}}\nb', 'x/SKILL.md.tmpl')).not.toThrow();
});
test('zero {{PREAMBLE}} passes (sections have none)', () => {
expect(() => assertSinglePreamble('no macro here', 'x/sections/y.md.tmpl')).not.toThrow();
});
test('a second occurrence throws with the template path — even in prose', () => {
// The original #2508 bug WAS a prose mention: "emitted by {{PREAMBLE}}'s
// preamble bash". Resolution is context-blind, so the guard must be too.
const tmpl = '{{PREAMBLE}}\n\n...later: emitted by {{PREAMBLE}}\'s preamble bash';
expect(() => assertSinglePreamble(tmpl, 'spec/SKILL.md.tmpl')).toThrow(/spec\/SKILL\.md\.tmpl.*2 times/);
});
});
// --- Parameterized resolver infrastructure tests ---
describe('parameterized resolver support', () => {
@@ -1537,8 +1563,11 @@ describe('parameterized resolver support', () => {
describe('preamble routing injection', () => {
const shipContent = readShipUnion();
test('preamble bash checks for routing section in CLAUDE.md', () => {
expect(shipContent).toContain('grep -q "## Skill routing" CLAUDE.md');
test('preamble bash checks for routing section in CLAUDE.md and AGENTS.md', () => {
// #2500: the probe iterates CLAUDE.md AND AGENTS.md — non-Claude hosts
// route skills via AGENTS.md, the cross-harness convention file.
expect(shipContent).toContain('for _RF in CLAUDE.md AGENTS.md');
expect(shipContent).toContain('grep -q "## Skill routing" "$_RF"');
expect(shipContent).toContain('HAS_ROUTING');
});
@@ -2003,8 +2032,15 @@ describe('Codex generation (--host codex)', () => {
// timeout-wrapper guidance documents the Codex CLI's own rollout-log
// location (a user-facing CLI path, same class as ~/.codex/logs/ in the
// codex skill), not the gstack Codex host install path.
// `~/.codex/config.toml` is the same user-facing class: the shared
// codexPreflight's model_unusable branch (#2477) points at the CLI's own
// config file, where the rejected `model =` pin lives.
expect(content).not.toContain('.agents/skills');
expect(content.replaceAll('~/.codex/sessions/', '')).not.toContain('~/.codex/');
expect(
content
.replaceAll('~/.codex/sessions/', '')
.replaceAll('~/.codex/config.toml', ''),
).not.toContain('~/.codex/');
});
test('Claude output unchanged: ship skill still uses .claude/skills/ paths', () => {
@@ -2012,8 +2048,14 @@ describe('Codex generation (--host codex)', () => {
expect(content).toContain('~/.claude/skills/gstack');
expect(content).not.toContain('.agents/skills');
// ~/.codex/sessions/ is the Codex CLI's rollout-log path (user-facing),
// documented by the adversarial-pass timeout guidance — see review test above.
expect(content.replaceAll('~/.codex/sessions/', '')).not.toContain('~/.codex/');
// documented by the adversarial-pass timeout guidance; ~/.codex/config.toml
// is the CLI's own config file (model_unusable guidance, #2477) — see the
// review test above.
expect(
content
.replaceAll('~/.codex/sessions/', '')
.replaceAll('~/.codex/config.toml', ''),
).not.toContain('~/.codex/');
});
test('Claude output unchanged: all Claude skills have zero Codex paths', () => {
@@ -2023,10 +2065,16 @@ describe('Codex generation (--host codex)', () => {
// codex + autoplan document the Codex CLI auth file (~/.codex/auth.json)
// and log path (~/.codex/logs/) — those are user-facing Codex CLI paths,
// not the gstack Codex host install path. ~/.codex/sessions/ (rollout
// logs, referenced by the review/ship timeout guidance) is the same
// user-facing class, so it is scrubbed before the ban.
// logs, referenced by the review/ship timeout guidance) and
// ~/.codex/config.toml (the model_unusable guidance in the shared
// codexPreflight, #2477) are the same user-facing class, so they are
// scrubbed before the ban.
if (skill.dir !== 'pair-agent' && skill.dir !== 'codex' && skill.dir !== 'autoplan') {
expect(content.replaceAll('~/.codex/sessions/', '')).not.toContain('~/.codex/');
expect(
content
.replaceAll('~/.codex/sessions/', '')
.replaceAll('~/.codex/config.toml', ''),
).not.toContain('~/.codex/');
}
// gstack-upgrade legitimately references .agents/skills for cross-platform detection
if (skill.dir !== 'gstack-upgrade') {
@@ -2330,10 +2378,13 @@ describe('setup script validation', () => {
test('Codex install uses link_codex_skill_dirs', () => {
// The Codex install section (section 5) should use the Codex function
// End marker: the next numbered section header (a marker that doesn't
// exist slices to EOF and the assertion reads unrelated sections).
const codexSection = setupContent.slice(
setupContent.indexOf('# 5. Install for Codex'),
setupContent.indexOf('# 6. Create')
setupContent.indexOf('# 6. Install for Kiro')
);
expect(setupContent.indexOf('# 6. Install for Kiro')).toBeGreaterThan(-1);
expect(codexSection).toContain('create_codex_runtime_root');
expect(codexSection).toContain('link_codex_skill_dirs');
expect(codexSection).not.toContain('link_claude_skill_dirs');
@@ -2378,7 +2429,10 @@ describe('setup script validation', () => {
const fnBody = setupContent.slice(fnStart, fnEnd);
expect(fnBody).toContain('mkdir -p "$target"');
// v1.36.0.0: routes through _link_or_copy helper for Windows fallback (cp on MSYS2/Git Bash).
expect(fnBody).toContain('_link_or_copy "$gstack_dir/$dir_name/SKILL.md" "$target/SKILL.md"');
// v1.67 (#2569): the source is render-aware — canonical SKILL.md, or the
// rendered :user variant from ${GSTACK_HOME}/render/claude when present.
expect(fnBody).toContain('_skill_md_src="$gstack_dir/$dir_name/SKILL.md"');
expect(fnBody).toContain('_link_or_copy "$_skill_md_src" "$target/SKILL.md"');
});
// REGRESSION: cleanup functions must handle both old symlinks AND new real-directory pattern
@@ -2415,7 +2469,10 @@ describe('setup script validation', () => {
const fnEnd = setupContent.indexOf('# ─── Helper: remove old unprefixed Claude skill entries', fnStart);
const fnBody = setupContent.slice(fnStart, fnEnd);
expect(fnBody).toContain('_gstack-command');
expect(fnBody).toContain('_link_or_copy "$gstack_dir/SKILL.md" "$target/SKILL.md"');
// #2511: the alias must be a rewritten COPY (unique frontmatter name),
// never a verbatim symlink of the canonical SKILL.md.
expect(fnBody).toContain('_install_alias_skill_md "$gstack_dir/SKILL.md" "$target" "_gstack-command"');
expect(fnBody).not.toContain('_link_or_copy "$gstack_dir/SKILL.md"');
const claudeSection = setupContent.slice(
setupContent.indexOf('# 4. Install for Claude'),
@@ -2467,6 +2524,86 @@ describe('setup script validation', () => {
expect(setupContent).toContain('OPENCODE_GSTACK="$OPENCODE_SKILLS/gstack"');
});
// --host cursor full install slice (#1358, PR #2547 by @szsunyuan re-derived)
test('auto mode detects Cursor via binary or ~/.cursor directory', () => {
expect(setupContent).toContain('command -v cursor');
expect(setupContent).toContain('[ -d "$HOME/.cursor" ] && INSTALL_CURSOR=1');
});
test('setup supports --host cursor with install section and Cursor skill path vars', () => {
expect(setupContent).toContain('INSTALL_CURSOR=');
expect(setupContent).toContain('CURSOR_SKILLS="$HOME/.cursor/skills"');
expect(setupContent).toContain('CURSOR_GSTACK="$CURSOR_SKILLS/gstack"');
expect(setupContent).toContain('create_cursor_runtime_root');
expect(setupContent).toContain('create_cursor_sidecar');
expect(setupContent).toContain('link_cursor_skill_dirs');
expect(setupContent).toContain('gstack ready (cursor).');
});
test('create_cursor_runtime_root exposes only Cursor runtime assets', () => {
const fnStart = setupContent.indexOf('create_cursor_runtime_root()');
const fnEnd = setupContent.indexOf('create_cursor_sidecar()', fnStart);
const fnBody = setupContent.slice(fnStart, fnEnd);
expect(fnBody).toContain('gstack/SKILL.md');
expect(fnBody).toContain('browse/dist');
expect(fnBody).toContain('browse/bin');
expect(fnBody).toContain('gstack-upgrade/SKILL.md');
expect(fnBody).toContain('checklist.md');
expect(fnBody).toContain('TODOS-format.md');
// bin scripts import ../lib — the two must travel together.
expect(fnBody).toContain('$cursor_gstack/lib');
expect(fnBody).not.toContain('design-checklist.md');
expect(fnBody).not.toContain('greptile-triage.md');
expect(fnBody).not.toContain('review/specialists');
expect(fnBody).not.toContain('qa/templates');
expect(fnBody).not.toContain('_link_or_copy "$gstack_dir" "$cursor_gstack"');
});
test('create_cursor_sidecar plants runtime assets without wiping generated SKILL.md', () => {
const fnStart = setupContent.indexOf('create_cursor_sidecar()');
const fnEnd = setupContent.indexOf('link_cursor_skill_dirs()', fnStart);
const fnBody = setupContent.slice(fnStart, fnEnd);
expect(fnBody).toContain('.cursor/skills/gstack');
expect(fnBody).toContain('bin');
expect(fnBody).toContain('browse/dist');
expect(fnBody).toContain('browse/bin');
expect(fnBody).toContain('ETHOS.md');
expect(fnBody).not.toContain('rm -rf');
});
test('link_cursor_skill_dirs skips the gstack runtime root directory', () => {
const fnStart = setupContent.indexOf('link_cursor_skill_dirs()');
const fnEnd = setupContent.indexOf('}', setupContent.indexOf('linked[@]', fnStart));
const fnBody = setupContent.slice(fnStart, fnEnd);
expect(fnBody).toContain('[ "$skill_name" = "gstack" ] && continue');
// #2444-aware guard: Windows bypass, else only replace symlink-or-missing.
expect(fnBody).toContain('[ "$IS_WINDOWS" -eq 1 ] || [ -L "$target" ] || [ ! -e "$target" ]');
});
// #2142 deleted existing ~/.cursor/skills/<name> dirs with `rm -rf "$target"`
// before relinking. That can wipe unowned Cursor skills. Only replace a
// symlink or a missing path; never the whole skills directory.
test('link_cursor_skill_dirs does not delete unowned Cursor skill directories', () => {
const fnStart = setupContent.indexOf('link_cursor_skill_dirs()');
const fnEnd = setupContent.indexOf('}', setupContent.indexOf('linked[@]', fnStart));
const fnBody = setupContent.slice(fnStart, fnEnd);
expect(fnBody).not.toContain('rm -rf "$target"');
expect(fnBody).not.toContain('rm -rf "$skills_dir"');
expect(setupContent).not.toContain('rm -rf "$CURSOR_SKILLS"');
});
test('Cursor install links generated skills before planting the sidecar', () => {
const cursorInstall = setupContent.slice(
setupContent.indexOf('# 6d. Install for Cursor'),
setupContent.indexOf('# 7. Create .agents/ sidecar'),
);
const linkCall = cursorInstall.indexOf('link_cursor_skill_dirs "$SOURCE_GSTACK_DIR"');
const sidecarCall = cursorInstall.indexOf('create_cursor_sidecar "$SOURCE_GSTACK_DIR"');
expect(linkCall).toBeGreaterThan(-1);
expect(sidecarCall).toBeGreaterThan(-1);
expect(linkCall).toBeLessThan(sidecarCall);
});
test('setup installs OpenCode skills into a nested gstack runtime root', () => {
expect(setupContent).toContain('create_opencode_runtime_root');
expect(setupContent).toContain('.opencode/skills');
@@ -3495,3 +3632,83 @@ describe('PREAMBLE resolution requires declared preamble-tier', () => {
expect(offenders).toEqual([]);
});
});
// ---------------------------------------------------------------------------
// #2499: gbrain MCP detection must read BOTH ~/.claude.json scopes.
// Claude Code registers MCP servers at user scope (.mcpServers) and project
// scope (.projects["/abs/path"].mcpServers — what `claude mcp add` without
// --scope user writes). The rendered brain-sync block previously read only
// user scope, so a correctly configured project-scoped brain was invisible.
// ---------------------------------------------------------------------------
describe('brain-sync block reads project-scoped MCP registrations (#2499)', () => {
const rendered = fs.readFileSync(path.join(ROOT, 'SKILL.md'), 'utf-8');
test('rendered _GBRAIN_MCP_ENTRY jq resolves project scope with nearest-ancestor cwd match', () => {
const line = rendered.split('\n').find((l) => l.includes('_GBRAIN_MCP_ENTRY=$('));
expect(line).toBeDefined();
// Project-scope read present, driven by $PWD.
expect(line!).toContain('--arg cwd "$PWD"');
expect(line!).toContain('.projects');
// User scope still resolved first.
expect(line!).toContain('.mcpServers.gbrain');
// The old user-scope-only filter is gone from the rendered output.
expect(rendered).not.toContain('.mcpServers.gbrain.type // .mcpServers.gbrain.transport');
expect(rendered).not.toContain(".mcpServers.gbrain.url // empty");
});
test('rendered _GBRAIN_MCP_TYPE and _GBRAIN_HOST extract from the resolved entry', () => {
const typeLine = rendered.split('\n').find((l) => l.includes('_GBRAIN_MCP_TYPE=$('));
const hostLine = rendered.split('\n').find((l) => l.includes('_GBRAIN_HOST=$('));
expect(typeLine).toBeDefined();
expect(hostLine).toBeDefined();
expect(typeLine!).toContain('_GBRAIN_MCP_ENTRY');
expect(hostLine!).toContain('_GBRAIN_MCP_ENTRY');
});
test('rendered jq lines FUNCTION: project-scoped registration resolves for a cwd inside the project', () => {
// Execute the exact rendered bytes, not a re-derivation: extract the
// _GBRAIN_MCP_ENTRY + _GBRAIN_MCP_TYPE lines from the generated SKILL.md
// and run them in bash against a fixture ~/.claude.json that carries ONLY
// a project-scoped gbrain registration.
const lines = rendered.split('\n');
const entryLine = lines.find((l) => l.includes('_GBRAIN_MCP_ENTRY=$('));
const typeLine = lines.find((l) => l.includes('_GBRAIN_MCP_TYPE=$('));
expect(entryLine).toBeDefined();
expect(typeLine).toBeDefined();
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-2499-home-'));
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-2499-proj-'));
const nestedCwd = path.join(projectDir, 'src', 'deep');
fs.mkdirSync(nestedCwd, { recursive: true });
try {
fs.writeFileSync(
path.join(tmpHome, '.claude.json'),
JSON.stringify({
projects: {
[projectDir]: {
mcpServers: { gbrain: { type: 'http', url: 'https://brain.example.com/mcp' } },
},
},
}),
);
const script = `cd "$1" || exit 1\n${entryLine!.trim()}\n${typeLine!.trim()}\necho "RESOLVED:$_GBRAIN_MCP_TYPE"`;
const r = spawnSync('bash', ['-c', script, 'bash', nestedCwd], {
encoding: 'utf-8',
env: { ...process.env, HOME: tmpHome },
timeout: 10_000,
});
expect(r.stdout).toContain('RESOLVED:http');
// Discriminator: a cwd OUTSIDE the project must NOT resolve it.
const outside = spawnSync('bash', ['-c', script, 'bash', os.tmpdir()], {
encoding: 'utf-8',
env: { ...process.env, HOME: tmpHome },
timeout: 10_000,
});
expect(outside.stdout).toContain('RESOLVED:\n');
} finally {
fs.rmSync(tmpHome, { recursive: true, force: true });
fs.rmSync(projectDir, { recursive: true, force: true });
}
});
});
+1 -1
View File
@@ -19,7 +19,7 @@ import {
writeReceipt,
} from '../lib/egress-receipt';
const ROOT = path.resolve(new URL(import.meta.url).pathname, '..', '..');
const ROOT = path.resolve(import.meta.path, '..', '..');
const BIN = path.join(ROOT, 'bin', 'gstack-egress');
let home: string;
+142 -1
View File
@@ -8,7 +8,7 @@
*/
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
import { mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync, mkdirSync, chmodSync } from "fs";
import { mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync, mkdirSync, chmodSync, symlinkSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
import { spawnSync } from "child_process";
@@ -62,6 +62,14 @@ describe("gstack-gbrain-sync CLI", () => {
expect(source).toContain("localEngineStatus");
});
it("uses GBrain's config environment when resolving dream sources", () => {
const source = readFileSync(SCRIPT, "utf-8");
expect(source).not.toContain("resolveCodeSourceId(root, process.env)");
expect(source).toContain("resolveCodeSourceId(root, gbrainEnv)");
expect(source).toContain("cycleCompleted(resolveCodeSourceId(root, gbrainEnv), gbrainEnv)");
});
it("--dry-run with --code-only reports the code import preview only", () => {
const home = makeTestHome();
const gstackHome = join(home, ".gstack");
@@ -122,6 +130,139 @@ describe("gstack-gbrain-sync CLI", () => {
rmSync(home, { recursive: true, force: true });
});
it("uses a local .gbrain-source in dry-run without spawning gbrain", () => {
const home = makeTestHome();
const gstackHome = join(home, ".gstack");
const bindir = mkdtempSync(join(tmpdir(), "gstack-pinned-source-bin-"));
const repo = mkdtempSync(join(tmpdir(), "gstack-pinned-source-repo-"));
const commandLog = join(home, "gbrain-commands.log");
mkdirSync(gstackHome, { recursive: true });
spawnSync("git", ["init", "--quiet", "-b", "main"], { cwd: repo });
writeFileSync(join(repo, ".gbrain-source"), "client-acme-app\n");
writeFileSync(join(bindir, "gbrain"), `#!/bin/sh
printf '%s\\n' "$*" >> "$GSTACK_TEST_GBRAIN_LOG"
exit 99
`);
chmodSync(join(bindir, "gbrain"), 0o755);
const r = spawnSync("bun", [SCRIPT, "--dry-run", "--code-only", "--quiet"], {
encoding: "utf-8",
timeout: 60000,
cwd: repo,
env: {
...process.env,
HOME: home,
GSTACK_HOME: gstackHome,
GSTACK_TEST_GBRAIN_LOG: commandLog,
PATH: `${bindir}:${process.env.PATH || ""}`,
},
});
expect(r.status).toBe(0);
expect(r.stdout).toContain("gbrain sync --strategy code --source client-acme-app");
expect(r.stdout).not.toContain("gbrain sources add");
expect(r.stdout).not.toContain("--federated");
expect(existsSync(commandLog)).toBe(false);
rmSync(repo, { recursive: true, force: true });
rmSync(bindir, { recursive: true, force: true });
rmSync(home, { recursive: true, force: true });
});
it("keeps a symlink-equivalent pinned source registered as-is", () => {
const home = makeTestHome();
const gstackHome = join(home, ".gstack");
const repo = mkdtempSync(join(tmpdir(), "gstack-pinned-source-repo-"));
const linkDir = mkdtempSync(join(tmpdir(), "gstack-pinned-source-link-"));
const link = join(linkDir, "repo");
const bindir = mkdtempSync(join(tmpdir(), "gstack-pinned-source-bin-"));
const commandLog = join(home, "gbrain-commands.log");
mkdirSync(gstackHome, { recursive: true });
mkdirSync(join(home, ".gbrain"), { recursive: true });
writeFileSync(join(home, ".gbrain", "config.json"), JSON.stringify({ engine: "pglite", database_url: "pglite:///test" }));
spawnSync("git", ["init", "--quiet", "-b", "main"], { cwd: repo });
writeFileSync(join(repo, ".gbrain-source"), "client-acme-app\n");
symlinkSync(repo, link, "dir");
writeFileSync(join(bindir, "gbrain"), `#!/bin/sh
printf '%s\\n' "$*" >> "$GSTACK_TEST_GBRAIN_LOG"
case "$*" in
--version) echo 'gbrain 0.42.0.0' ;;
"sources list --json") echo '{"sources":[{"id":"client-acme-app","local_path":"${link}","page_count":1}]}' ;;
"sync --strategy code --source client-acme-app"|"sources attach client-acme-app") ;;
*) echo "unexpected gbrain command: $*" >&2; exit 1 ;;
esac
`);
chmodSync(join(bindir, "gbrain"), 0o755);
const r = spawnSync("bun", [SCRIPT, "--code-only", "--quiet"], {
encoding: "utf-8",
timeout: 60000,
cwd: link,
env: {
...process.env,
HOME: home,
GSTACK_HOME: gstackHome,
GSTACK_TEST_GBRAIN_LOG: commandLog,
PATH: `${bindir}:${process.env.PATH || ""}`,
},
});
const commands = readFileSync(commandLog, "utf-8");
expect(r.status).toBe(0);
expect(commands).toContain("sync --strategy code --source client-acme-app");
expect(commands).toContain("sources attach client-acme-app");
expect(commands).not.toMatch(/^sources (add|remove) /m);
rmSync(repo, { recursive: true, force: true });
rmSync(linkDir, { recursive: true, force: true });
rmSync(bindir, { recursive: true, force: true });
rmSync(home, { recursive: true, force: true });
});
it("uses a local pin for a dry-run dream without spawning gbrain", () => {
const home = makeTestHome();
const gstackHome = join(home, ".gstack");
const bindir = mkdtempSync(join(tmpdir(), "gstack-pinned-dream-bin-"));
const repo = mkdtempSync(join(tmpdir(), "gstack-pinned-dream-repo-"));
mkdirSync(gstackHome, { recursive: true });
spawnSync("git", ["init", "--quiet", "-b", "main"], { cwd: repo });
writeFileSync(join(repo, ".gbrain-source"), "client-acme-app\n");
writeFileSync(join(bindir, "gbrain"), "#!/bin/sh\nexit 99\n");
chmodSync(join(bindir, "gbrain"), 0o755);
const r = spawnSync("bun", [SCRIPT, "--dry-run", "--dream", "--no-code", "--no-memory", "--no-brain-sync", "--quiet"], {
encoding: "utf-8",
timeout: 60000,
cwd: repo,
env: { ...process.env, HOME: home, GSTACK_HOME: gstackHome, PATH: `${bindir}:${process.env.PATH || ""}` },
});
expect(r.status).toBe(0);
expect(r.stdout).toContain("gbrain dream --source client-acme-app");
rmSync(repo, { recursive: true, force: true });
rmSync(bindir, { recursive: true, force: true });
rmSync(home, { recursive: true, force: true });
});
it("falls back to a derived source when .gbrain-source cannot be read", () => {
const home = makeTestHome();
const gstackHome = join(home, ".gstack");
const repo = mkdtempSync(join(tmpdir(), "gstack-unreadable-pin-repo-"));
mkdirSync(gstackHome, { recursive: true });
spawnSync("git", ["init", "--quiet", "-b", "main"], { cwd: repo });
mkdirSync(join(repo, ".gbrain-source"));
const r = spawnSync("bun", [SCRIPT, "--dry-run", "--code-only", "--quiet"], {
encoding: "utf-8",
timeout: 60000,
cwd: repo,
env: { ...process.env, HOME: home, GSTACK_HOME: gstackHome },
});
expect(r.status).toBe(0);
expect(r.stdout).toMatch(/gbrain sources add gstack-code-/);
rmSync(repo, { recursive: true, force: true });
rmSync(home, { recursive: true, force: true });
});
it("derived source ids are gbrain-valid (≤32 chars, alnum + interior hyphens, no dots) for any remote", () => {
// gbrain enforces source ids to be 1-32 lowercase alnum chars with optional interior
// hyphens. Pre-fix, the slug came from canonicalizeRemote() with only `/` and
+7 -3
View File
@@ -462,10 +462,13 @@ describe("detectEngineTier", () => {
// Regression test for #1415: gbrain >=0.25 doctor output dropped the
// top-level `engine` field. The detect path must fall back to config.json.
// We force the doctor call to fail (PATH stripped of gbrain) and write a
// synthetic config to GBRAIN_HOME so the fallback path is deterministic.
// synthetic config under GBRAIN_HOME so the fallback path is
// deterministic. Per gbrain's configDir() contract (#2521), GBRAIN_HOME
// is a parent dir — the config lives at $GBRAIN_HOME/.gbrain/config.json.
process.env.PATH = "/nonexistent-no-gbrain-here";
mkdirSync(join(testGbrainHome, ".gbrain"), { recursive: true });
writeFileSync(
join(testGbrainHome, "config.json"),
join(testGbrainHome, ".gbrain", "config.json"),
JSON.stringify({ engine: "postgres", database_url: "postgresql://test/example" }),
"utf-8"
);
@@ -500,8 +503,9 @@ exit 0
{ mode: 0o755 }
);
process.env.PATH = `${binDir}:${process.env.PATH || ""}`;
mkdirSync(join(testGbrainHome, ".gbrain"), { recursive: true });
writeFileSync(
join(testGbrainHome, "config.json"),
join(testGbrainHome, ".gbrain", "config.json"),
JSON.stringify({ engine: "pglite" }),
"utf-8"
);
+43
View File
@@ -818,3 +818,46 @@ exit 0
rmSync(home, { recursive: true, force: true });
});
});
// #2105: current Codex rollout records are
// { type: 'response_item', payload: { type: 'message', role, content: [...] } }
// — the legacy payload.message branch never fired on them, so every Codex
// session imported as an empty shell (message_count: 0, 243/243 on the
// reporting machine).
describe("#2105 codex response_item rollout shape", () => {
it("extracts messages from response_item records", async () => {
const { parseTranscriptJsonl } = await import("../bin/gstack-memory-ingest");
const dir = mkdtempSync(join(tmpdir(), "ingest-2105-"));
const file = join(dir, "rollout-2026-06-01.jsonl");
writeFileSync(file, [
JSON.stringify({ type: "session_meta", payload: { id: "s1", cwd: "/tmp/x" }, timestamp: "2026-06-01T00:00:00Z" }),
JSON.stringify({ type: "response_item", payload: { type: "message", role: "user", content: [{ type: "input_text", text: "hello codex" }] } }),
JSON.stringify({ type: "response_item", payload: { type: "message", role: "assistant", content: [{ type: "output_text", text: "hello human" }] } }),
// Non-message response_items must not count as messages.
JSON.stringify({ type: "response_item", payload: { type: "reasoning", summary: [] } }),
].join("\n") + "\n");
const parsed = parseTranscriptJsonl(file)!;
expect(parsed).not.toBeNull();
expect(parsed.agent).toBe("codex");
expect(parsed.message_count).toBe(2);
expect(parsed.body).toContain("## User\n\nhello codex");
expect(parsed.body).toContain("## Assistant\n\nhello human");
rmSync(dir, { recursive: true, force: true });
});
it("legacy payload.message shape still parses", async () => {
const { parseTranscriptJsonl } = await import("../bin/gstack-memory-ingest");
const dir = mkdtempSync(join(tmpdir(), "ingest-2105-legacy-"));
const file = join(dir, "rollout-legacy.jsonl");
writeFileSync(file, [
JSON.stringify({ type: "session_meta", payload: { id: "s2", cwd: "/tmp/y" }, timestamp: "2026-06-01T00:00:00Z" }),
JSON.stringify({ payload: { message: { role: "user", content: "old shape" } } }),
].join("\n") + "\n");
const parsed = parseTranscriptJsonl(file)!;
expect(parsed.message_count).toBe(1);
expect(parsed.body).toContain("## User\n\nold shape");
rmSync(dir, { recursive: true, force: true });
});
});
+221 -1
View File
@@ -13,9 +13,12 @@ import {
fmtVersion,
bumpVersion,
cmpVersion,
versionWidth,
extractVersion,
pickNextSlot,
markActiveSiblings,
resolveVersionPath,
fetchGitClaimed,
} from "../bin/gstack-next-version";
describe("parseVersion", () => {
@@ -29,8 +32,20 @@ describe("parseVersion", () => {
expect(parseVersion(" 1.2.3.4 \n")).toEqual([1, 2, 3, 4]);
});
test("accepts 3-digit semver, padding the micro slot (#2501)", () => {
// 3-digit repos (a package.json holding plain semver) used to fail parsing
// outright, which exited this CLI 2 on EVERY run — and since this CLI is
// the queue-collision check, /ship then fell back to naive local
// arithmetic and duplicate version slots shipped silently. The pad keeps
// comparison uniform; versionWidth narrows output back.
expect(parseVersion("0.99.2")).toEqual([0, 99, 2, 0]);
expect(parseVersion("1.2.3")).toEqual([1, 2, 3, 0]);
expect(versionWidth("0.99.2")).toBe(3);
expect(versionWidth("1.6.3.0")).toBe(4);
});
test("rejects malformed", () => {
expect(parseVersion("1.2.3")).toBeNull();
expect(parseVersion("1.2")).toBeNull();
expect(parseVersion("1.2.3.4.5")).toBeNull();
expect(parseVersion("v1.2.3.4")).toBeNull();
expect(parseVersion("")).toBeNull();
@@ -39,6 +54,49 @@ describe("parseVersion", () => {
});
});
describe("3-digit repos keep their width (#2501)", () => {
test("formatting narrows to the repo's own width", () => {
expect(fmtVersion([0, 99, 3, 0], 3)).toBe("0.99.3");
expect(fmtVersion([0, 99, 3, 0], 4)).toBe("0.99.3.0");
expect(fmtVersion([0, 99, 3, 0])).toBe("0.99.3.0"); // default stays 4-digit
});
test("micro is carried out as patch when there is no micro component", () => {
// /ship auto-picks MICRO by default. Erroring would make it unusable in
// every 3-digit repo; a no-op would be worse — it would write back the
// version it started with and claim a slot already taken.
expect(bumpVersion([0, 99, 2, 0], "micro", 3)).toEqual([0, 99, 3, 0]);
expect(bumpVersion([0, 99, 2, 0], "patch", 3)).toEqual([0, 99, 3, 0]);
expect(bumpVersion([0, 99, 2, 3], "micro", 4)).toEqual([0, 99, 2, 4]); // 4-digit unchanged
});
test("slot picking stays inside the repo's width", () => {
const { version } = pickNextSlot([0, 99, 2, 0], [[0, 99, 5, 0]], "patch", 3);
expect(fmtVersion(version, 3)).toBe("0.99.6");
});
});
describe("extractVersion (#2501)", () => {
test("reads .version when the version-path is a package.json", () => {
const pkg = JSON.stringify({ name: "frontend", version: "0.99.2", private: true });
expect(extractVersion(pkg, "frontend/package.json")).toBe("0.99.2");
expect(extractVersion(pkg, "deep/nested/package.json")).toBe("0.99.2");
});
test("reads raw text for a plain VERSION file", () => {
expect(extractVersion("1.6.3.0\n", "VERSION")).toBe("1.6.3.0");
expect(extractVersion(" 1.6.3.0 ", "version/CURRENT")).toBe("1.6.3.0");
});
test("a JSON path that isn't valid JSON yields empty, not garbage", () => {
// The old readers ran a package.json through a whitespace strip and handed
// the caller '{"name":"frontend",...' as if it were a version. Empty lets
// callers fall back loudly.
expect(extractVersion("{ not json", "package.json")).toBe("");
expect(extractVersion(JSON.stringify({ name: "x" }), "package.json")).toBe("");
});
});
describe("bumpVersion", () => {
test("major zeros everything right", () => {
expect(bumpVersion([1, 6, 3, 0], "major")).toEqual([2, 0, 0, 0]);
@@ -322,6 +380,168 @@ describe("default-base detection (no --base)", () => {
// Integration smoke — only runs if gh is available and authenticated. Confirms
// the CLI executes end-to-end against real APIs without crashing.
describe("offline output contract (what /ship branches on, #2545)", () => {
// /ship's Step 12 reads `.fallback` to decide whether the pick is
// trustworthy when the PR queue is unreachable. That field is therefore
// load-bearing prose-to-code coupling: if it silently stopped being emitted,
// /ship would read undefined, treat the run as fully online, and lose the
// "verify no sibling holds it" prompt. Asserted end-to-end with a stub `gh`
// that always fails, which is what an expired token or an offline laptop
// looks like from here.
test("emits fallback:'git' and still returns a version when gh fails", async () => {
const stubDir = mkdtempSync(join(tmpdir(), "nextver-stubgh-"));
writeFileSync(join(stubDir, "gh"), "#!/bin/sh\nexit 1\n", { mode: 0o755 });
const proc = Bun.spawnSync(
["bun", "run", "./bin/gstack-next-version", "--base", "main",
"--bump", "patch", "--current-version", "1.0.0.0", "--workspace-root", "null"],
{ env: { ...process.env, PATH: `${stubDir}:${process.env.PATH}` } },
);
rmSync(stubDir, { recursive: true, force: true });
const out = JSON.parse(new TextDecoder().decode(proc.stdout));
expect(out.offline).toBe(true);
expect(out.fallback).toBe("git");
// The whole point: degraded queue view, NOT a degraded allocation.
expect(out.version).toMatch(/^\d+\.\d+\.\d+\.\d+$/);
expect(out.warnings.join(" ")).toContain("allocated from git");
}, 30000);
test("online runs leave fallback null", async () => {
const proc = Bun.spawnSync(
["bun", "run", "./bin/gstack-next-version", "--base", "main",
"--bump", "patch", "--current-version", "1.0.0.0", "--workspace-root", "null"],
);
const out = JSON.parse(new TextDecoder().decode(proc.stdout));
if (out.offline) return; // no network / no gh auth on this machine: nothing to assert
expect(out.fallback).toBe(null);
}, 30000);
});
describe("fetchGitClaimed (offline allocation — the anti-duplicate fallback, #2545)", () => {
// Why this exists: when `gh pr list` failed, the util returned
// `offline:true` with an EMPTY claim set and /ship's instruction was
// "fall back to local BUMP_LEVEL arithmetic". Local arithmetic cannot see a
// sibling's claim, so it re-allocated a version an open PR already held.
// That produced two commits reading v0.1.57.0 on a downstream repo's main
// (plus three earlier pairs found in the same audit). Git knows what the API
// was asked for, so offline now degrades the QUEUE VIEW, not the ALLOCATION.
function git(cwd: string, ...args: string[]) {
return Bun.spawnSync(["git", "-c", "user.email=t@t", "-c", "user.name=t", ...args], { cwd });
}
function fixture(): string {
const dir = mkdtempSync(join(tmpdir(), "nextver-git-"));
git(dir, "init", "-q", "-b", "main");
writeFileSync(join(dir, "VERSION"), "0.1.66.0\n");
git(dir, "add", "-A");
git(dir, "commit", "-qm", "v0.1.66.0 chore: base");
// A sibling PR branch that already claimed 0.1.67.0, present as a fetched
// remote-tracking ref — which is the shape a real `git fetch` leaves.
git(dir, "checkout", "-q", "-b", "sibling");
writeFileSync(join(dir, "VERSION"), "0.1.67.0\n");
git(dir, "add", "-A");
git(dir, "commit", "-qm", "v0.1.67.0 feat: sibling claimed this");
const sha = new TextDecoder().decode(git(dir, "rev-parse", "HEAD").stdout).trim();
git(dir, "checkout", "-q", "main");
git(dir, "update-ref", "refs/remotes/origin/sibling", sha);
git(dir, "update-ref", "refs/remotes/origin/main", "main");
return dir;
}
test("finds a sibling branch's claim from remote-tracking refs", () => {
const dir = fixture();
const cwd = process.cwd();
try {
process.chdir(dir);
const warnings: string[] = [];
const claims = fetchGitClaimed("main", "VERSION", warnings);
const versions = claims.map((c) => c.version);
expect(versions).toContain("0.1.67.0");
} finally {
process.chdir(cwd);
rmSync(dir, { recursive: true, force: true });
}
});
test("the sibling's claim is enough to push the pick past it", () => {
// The end-to-end consequence: with the claim visible, pickNextSlot lands
// on 0.1.68.0 instead of re-issuing the sibling's 0.1.67.0.
const dir = fixture();
const cwd = process.cwd();
try {
process.chdir(dir);
const claims = fetchGitClaimed("main", "VERSION", []);
const base = parseVersion("0.1.66.0")!;
const claimed = claims
.map((c) => parseVersion(c.version))
.filter((v): v is [number, number, number, number] => v !== null)
.filter((v) => cmpVersion(v, base) > 0);
const { version } = pickNextSlot(base, claimed, "patch");
expect(fmtVersion(version)).toBe("0.1.68.0");
} finally {
process.chdir(cwd);
rmSync(dir, { recursive: true, force: true });
}
});
test("also reports versions already shipped on the base", () => {
// Catches a number that merged and was then re-picked — the VERSION file
// alone cannot see that, because it only holds the newest value.
const dir = fixture();
const cwd = process.cwd();
try {
process.chdir(dir);
const claims = fetchGitClaimed("main", "VERSION", []);
const shipped = claims.filter((c) => c.branch.startsWith("(shipped on"));
expect(shipped.map((c) => c.version)).toContain("0.1.66.0");
} finally {
process.chdir(cwd);
rmSync(dir, { recursive: true, force: true });
}
});
test("reads a JSON version-path on remote refs and keeps the branch's own width", () => {
// A sibling repo pinned to frontend/package.json (#2501): its claim is the
// JSON .version, not the whitespace-stripped file bytes.
const dir = mkdtempSync(join(tmpdir(), "nextver-gitjson-"));
const cwd = process.cwd();
try {
git(dir, "init", "-q", "-b", "main");
mkdirSync(join(dir, "frontend"), { recursive: true });
writeFileSync(join(dir, "frontend", "package.json"), JSON.stringify({ name: "f", version: "0.99.2" }, null, 2) + "\n");
git(dir, "add", "-A");
git(dir, "commit", "-qm", "base");
git(dir, "checkout", "-q", "-b", "sibling");
writeFileSync(join(dir, "frontend", "package.json"), JSON.stringify({ name: "f", version: "0.99.3" }, null, 2) + "\n");
git(dir, "add", "-A");
git(dir, "commit", "-qm", "sibling claim");
const sha = new TextDecoder().decode(git(dir, "rev-parse", "HEAD").stdout).trim();
git(dir, "checkout", "-q", "main");
git(dir, "update-ref", "refs/remotes/origin/sibling", sha);
process.chdir(dir);
const claims = fetchGitClaimed("main", "frontend/package.json", []);
expect(claims.map((c) => c.version)).toContain("0.99.3");
} finally {
process.chdir(cwd);
rmSync(dir, { recursive: true, force: true });
}
});
test("degrades to a warning, never a throw, outside a git repo", () => {
const dir = mkdtempSync(join(tmpdir(), "nextver-nogit-"));
const cwd = process.cwd();
try {
process.chdir(dir);
const warnings: string[] = [];
const claims = fetchGitClaimed("main", "VERSION", warnings);
expect(claims).toEqual([]);
expect(warnings.length).toBeGreaterThan(0);
} finally {
process.chdir(cwd);
rmSync(dir, { recursive: true, force: true });
}
});
});
describe("integration (smoke)", () => {
// Bumps timeout to 30s — the test spawns a real `bun run` subprocess that
// does a `gh pr list` against the live GitHub API to inspect claimed slots.
@@ -111,6 +111,55 @@ describe('add-event', () => {
expect(s.hooks.PreToolUse[0].hooks[0].command).toBe('/v2');
});
test('dedup includes command: same (event, matcher, command) with different source updates in place', () => {
run([
'add-event',
'--event', 'PostToolUse',
'--matcher', '(AskUserQuestion|mcp__.*__AskUserQuestion)',
'--command', '/abs/path/to/question-log-hook',
'--source', 'source-A',
'--timeout', '5',
]);
run([
'add-event',
'--event', 'PostToolUse',
'--matcher', '(AskUserQuestion|mcp__.*__AskUserQuestion)',
'--command', '/abs/path/to/question-log-hook',
'--source', 'source-B',
'--timeout', '5',
]);
const s = settings();
expect(s.hooks.PostToolUse).toHaveLength(1);
expect(s.hooks.PostToolUse[0]._gstack_source).toBe('source-B');
});
test('dedup includes command: untagged entry with same command is updated not duplicated', () => {
fs.writeFileSync(
settingsFile,
JSON.stringify({
hooks: {
PostToolUse: [
{
matcher: '(AskUserQuestion|mcp__.*__AskUserQuestion)',
hooks: [{ type: 'command', command: '/abs/path/to/question-log-hook', timeout: 5 }],
},
],
},
}, null, 2),
);
run([
'add-event',
'--event', 'PostToolUse',
'--matcher', '(AskUserQuestion|mcp__.*__AskUserQuestion)',
'--command', '/abs/path/to/question-log-hook',
'--source', 'plan-tune-cathedral',
'--timeout', '5',
]);
const s = settings();
expect(s.hooks.PostToolUse).toHaveLength(1);
expect(s.hooks.PostToolUse[0]._gstack_source).toBe('plan-tune-cathedral');
});
test('preserves unrelated existing hooks', () => {
fs.writeFileSync(
settingsFile,
+6 -1
View File
@@ -39,9 +39,14 @@ function runSlug(
tmpHome: string,
extraEnv: Record<string, string> = {},
): SpawnSyncReturns<string> {
// GSTACK_HOME must be pinned to the temp home too: the cache dir is
// GSTACK_HOME-aware (matching lib/bin-context.ts's native port), and a
// sibling test file leaking process.env.GSTACK_HOME in a shared-process
// shard would otherwise point the bin at a different cache than the one
// these tests seed and assert on.
// Scrub PATH so we always use system bash + system git; pass HOME so the
// script's cache writes land in tmpHome, never AJ's real ~/.gstack.
const env = { ...process.env, HOME: tmpHome, ...extraEnv };
const env = { ...process.env, HOME: tmpHome, GSTACK_HOME: path.join(tmpHome, '.gstack'), ...extraEnv };
return spawnSync('bash', [SCRIPT], {
cwd,
env,
+41
View File
@@ -63,3 +63,44 @@ describe('gstack-slug cache-read sanitization', () => {
}
});
});
// The GSTACK_PROJECT_SLUG escape hatch is per-invocation, never durable: a
// test exporting it from the repo root once rebound the ENTIRE repo's session
// state (evals, decisions, timelines) to the test's slug via the cwd cache.
// The cache is also GSTACK_HOME-aware now, matching lib/bin-context.ts's
// native port — temp-home runs must not litter the real ~/.gstack (observed:
// 2,528 stale temp-cwd entries).
describe('slug cache hygiene', () => {
test('an env-override run never writes the cwd cache', () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'slug-env-'));
try {
const r = spawnSync(['bash', SLUG_BIN], {
cwd: os.tmpdir(),
env: { ...process.env, GSTACK_HOME: home, GSTACK_PROJECT_SLUG: 'override-slug' },
});
expect(r.stdout.toString()).toContain('SLUG=override-slug');
expect(fs.existsSync(path.join(home, 'slug-cache'))).toBe(false);
} finally {
fs.rmSync(home, { recursive: true, force: true });
}
});
test('an env-less run caches under GSTACK_HOME, not $HOME', () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'slug-home-'));
try {
// Strip any ambient override: a sibling test leaking
// GSTACK_PROJECT_SLUG in a shared-process shard would flip this run
// into override mode, which (correctly) skips the cache write.
const { GSTACK_PROJECT_SLUG: _drop, ...ambient } = process.env;
const r = spawnSync(['bash', SLUG_BIN], {
cwd: os.tmpdir(),
env: { ...ambient, GSTACK_HOME: home },
});
expect(r.exitCode).toBe(0);
const entries = fs.readdirSync(path.join(home, 'slug-cache'));
expect(entries.length).toBe(1);
} finally {
fs.rmSync(home, { recursive: true, force: true });
}
});
});
+454 -8
View File
@@ -39,10 +39,18 @@ describe('VERSION_RE', () => {
test('accepts 4-digit semver', () => {
expect(VERSION_RE.test('1.2.3.4')).toBe(true);
});
test('rejects 3-digit and garbage', () => {
expect(VERSION_RE.test('1.2.3')).toBe(false);
test('accepts 3-digit semver too (#2501)', () => {
// A repo whose pinned version source is a package.json holds plain
// 3-digit semver. Rejecting it meant /ship could not write a version in
// such a repo at all.
expect(VERSION_RE.test('1.2.3')).toBe(true);
expect(VERSION_RE.test('0.99.2')).toBe(true);
});
test('rejects garbage', () => {
expect(VERSION_RE.test('1.2')).toBe(false);
expect(VERSION_RE.test('v1.2.3.4')).toBe(false);
expect(VERSION_RE.test('1.2.3.4-rc')).toBe(false);
expect(VERSION_RE.test('1.2.3.4.5')).toBe(false);
});
});
@@ -54,16 +62,21 @@ describe('write (FRESH bump)', () => {
fs.writeFileSync(path.join(dir, 'VERSION'), '1.0.0.0\n');
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'x', version: '1.0.0.0', scripts: { t: 'y' } }, null, 2) + '\n');
const out = execFileSync('bun', [BIN, 'write', '--version', '1.1.0.0'], { cwd: dir }).toString();
expect(JSON.parse(out)).toEqual({ wrote: '1.1.0.0', packageJson: true });
expect(JSON.parse(out)).toEqual({
wrote: '1.1.0.0', packageJson: true, packageJsonPath: 'package.json',
packageJsonVersion: '1.1.0', packageLock: false,
});
expect(fs.readFileSync(path.join(dir, 'VERSION'), 'utf-8').trim()).toBe('1.1.0.0');
const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf-8'));
expect(pkg.version).toBe('1.1.0.0');
// Decision 11: the manifest carries the npm-valid 3-digit translation;
// VERSION keeps the 4-digit form and stays the source of truth.
expect(pkg.version).toBe('1.1.0');
expect(pkg.scripts).toEqual({ t: 'y' }); // untouched
});
test('rejects a malformed version with exit 2', () => {
let code = 0;
try { execFileSync('bun', [BIN, 'write', '--version', '1.2.3'], { cwd: dir, stdio: 'pipe' }); }
try { execFileSync('bun', [BIN, 'write', '--version', '1.2.3.4.5'], { cwd: dir, stdio: 'pipe' }); }
catch (e: any) { code = e.status; }
expect(code).toBe(2);
});
@@ -72,7 +85,10 @@ describe('write (FRESH bump)', () => {
const d2 = fs.mkdtempSync(path.join(os.tmpdir(), 'vbump-noPkg-'));
fs.writeFileSync(path.join(d2, 'VERSION'), '0.1.0.0\n');
const out = execFileSync('bun', [BIN, 'write', '--version', '0.2.0.0'], { cwd: d2 }).toString();
expect(JSON.parse(out)).toEqual({ wrote: '0.2.0.0', packageJson: false });
expect(JSON.parse(out)).toEqual({
wrote: '0.2.0.0', packageJson: false, packageJsonPath: null,
packageJsonVersion: null, packageLock: false,
});
expect(fs.readFileSync(path.join(d2, 'VERSION'), 'utf-8').trim()).toBe('0.2.0.0');
fs.rmSync(d2, { recursive: true, force: true });
});
@@ -86,8 +102,10 @@ describe('repair (DRIFT_STALE_PKG)', () => {
fs.writeFileSync(path.join(dir, 'VERSION'), '2.0.0.0\n');
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'x', version: '1.9.0.0' }, null, 2) + '\n');
const out = execFileSync('bun', [BIN, 'repair'], { cwd: dir }).toString();
expect(JSON.parse(out)).toEqual({ repaired: '2.0.0.0' });
expect(JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf-8')).version).toBe('2.0.0.0');
expect(JSON.parse(out)).toEqual({
repaired: '2.0.0.0', packageJsonPath: 'package.json', packageJsonVersion: '2.0.0',
});
expect(JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf-8')).version).toBe('2.0.0');
expect(fs.readFileSync(path.join(dir, 'VERSION'), 'utf-8').trim()).toBe('2.0.0.0'); // unchanged
});
@@ -100,6 +118,81 @@ describe('repair (DRIFT_STALE_PKG)', () => {
});
});
describe('write/repair sync npm lockfiles (both version fields, #2567)', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vbump-lock-'));
afterAll(() => { try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* noop */ } });
const lock = (v: string) => JSON.stringify({
name: 'x', version: v, lockfileVersion: 3,
packages: { '': { name: 'x', version: v }, 'node_modules/a': { version: '9.9.9' } },
}, null, 2) + '\n';
test('write updates top-level version and packages[""].version, leaves deps alone', () => {
fs.writeFileSync(path.join(dir, 'VERSION'), '1.0.0.0\n');
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'x', version: '1.0.0' }, null, 2) + '\n');
fs.writeFileSync(path.join(dir, 'package-lock.json'), lock('1.0.0'));
const out = execFileSync('bun', [BIN, 'write', '--version', '1.1.0.0'], { cwd: dir }).toString();
expect(JSON.parse(out)).toEqual({
wrote: '1.1.0.0', packageJson: true, packageJsonPath: 'package.json',
packageJsonVersion: '1.1.0', packageLock: true,
});
const l = JSON.parse(fs.readFileSync(path.join(dir, 'package-lock.json'), 'utf-8'));
expect(l.version).toBe('1.1.0');
expect(l.packages[''].version).toBe('1.1.0');
expect(l.packages['node_modules/a'].version).toBe('9.9.9'); // untouched
});
test('repair heals a stale lockfile alongside package.json', () => {
fs.writeFileSync(path.join(dir, 'VERSION'), '2.0.0.0\n');
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'x', version: '1.9.0' }, null, 2) + '\n');
fs.writeFileSync(path.join(dir, 'package-lock.json'), lock('1.9.0'));
execFileSync('bun', [BIN, 'repair'], { cwd: dir });
const l = JSON.parse(fs.readFileSync(path.join(dir, 'package-lock.json'), 'utf-8'));
expect(l.version).toBe('2.0.0');
expect(l.packages[''].version).toBe('2.0.0');
});
test('lockfileVersion 1 (no packages map) syncs top-level only, no crash', () => {
fs.writeFileSync(path.join(dir, 'VERSION'), '3.0.0.0\n');
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'x', version: '2.9.0' }, null, 2) + '\n');
fs.writeFileSync(path.join(dir, 'package-lock.json'), JSON.stringify({ name: 'x', version: '2.9.0', lockfileVersion: 1 }, null, 2) + '\n');
execFileSync('bun', [BIN, 'repair'], { cwd: dir });
const l = JSON.parse(fs.readFileSync(path.join(dir, 'package-lock.json'), 'utf-8'));
expect(l.version).toBe('3.0.0');
expect(l.packages).toBeUndefined();
});
test('npm-shrinkwrap.json is synced too when present (never created)', () => {
const d2 = fs.mkdtempSync(path.join(os.tmpdir(), 'vbump-shrink-'));
fs.writeFileSync(path.join(d2, 'VERSION'), '1.0.0.0\n');
fs.writeFileSync(path.join(d2, 'package.json'), JSON.stringify({ name: 'x', version: '1.0.0' }, null, 2) + '\n');
fs.writeFileSync(path.join(d2, 'npm-shrinkwrap.json'), lock('1.0.0').replace('package-lock', 'npm-shrinkwrap'));
const out = execFileSync('bun', [BIN, 'write', '--version', '1.1.0.0'], { cwd: d2 }).toString();
expect(JSON.parse(out).packageLock).toBe(true);
const l = JSON.parse(fs.readFileSync(path.join(d2, 'npm-shrinkwrap.json'), 'utf-8'));
expect(l.version).toBe('1.1.0');
expect(l.packages[''].version).toBe('1.1.0');
// No package-lock.json invented alongside it.
expect(fs.existsSync(path.join(d2, 'package-lock.json'))).toBe(false);
fs.rmSync(d2, { recursive: true, force: true });
});
test('malformed lockfile fails the write with exit 3 (half-write is loud, not silent)', () => {
const d3 = fs.mkdtempSync(path.join(os.tmpdir(), 'vbump-badlock-'));
fs.writeFileSync(path.join(d3, 'VERSION'), '1.0.0.0\n');
fs.writeFileSync(path.join(d3, 'package.json'), JSON.stringify({ name: 'x', version: '1.0.0.0' }, null, 2) + '\n');
fs.writeFileSync(path.join(d3, 'package-lock.json'), '{ not json');
let code = 0;
try { execFileSync('bun', [BIN, 'write', '--version', '1.1.0.0'], { cwd: d3, stdio: 'pipe' }); }
catch (e: any) { code = e.status; }
expect(code).toBe(3);
// VERSION was written before the failure — exactly the half-write the
// exit-3 contract exists to surface.
expect(fs.readFileSync(path.join(d3, 'VERSION'), 'utf-8').trim()).toBe('1.1.0.0');
fs.rmSync(d3, { recursive: true, force: true });
});
});
describe('classify (idempotency over a real git base)', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vbump-classify-'));
afterAll(() => { try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* noop */ } });
@@ -131,3 +224,356 @@ describe('classify (idempotency over a real git base)', () => {
expect(parsed.currentVersion).toBe('1.1.0.0');
});
});
/**
* A repo whose single source of truth is a package.json at a non-root path,
* holding plain 3-digit semver the shape gstack's native VERSION-file
* assumption failed closed on (#2501). Before this, classify reported
* {state: FRESH, baseVersion: "0.0.0.0", pkgExists: false} no matter what the
* repo's real version was: it looked for a root VERSION file and a root
* package.json, found neither, and reported a pristine repo at version zero.
*
* These cases pass --version-path explicitly; the .gstack/version-path pin
* flows through the same reader once classify/write/repair resolve the pin's
* repo-relative form (#2462, covered in its own suite below the pin fix).
*/
describe('package.json as the version source (monorepo, 3-digit, #2501)', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vbump-pkgsrc-'));
afterAll(() => { try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* noop */ } });
const pkgRel = 'frontend/package.json';
const pkgAbs = path.join(dir, pkgRel);
fs.mkdirSync(path.join(dir, 'frontend'), { recursive: true });
fs.writeFileSync(pkgAbs, JSON.stringify({ name: 'frontend', version: '0.99.2', private: true, scripts: { dev: 'next dev' } }, null, 2) + '\n');
execFileSync('git', ['init', '-q', '-b', 'main'], { cwd: dir });
execFileSync('git', ['config', 'user.email', 't@e.com'], { cwd: dir });
execFileSync('git', ['config', 'user.name', 't'], { cwd: dir });
execFileSync('git', ['add', '-A'], { cwd: dir });
execFileSync('git', ['commit', '-qm', 'v0.99.2 base'], { cwd: dir });
const head = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: dir }).toString().trim();
fs.mkdirSync(path.join(dir, '.git', 'refs', 'remotes', 'origin'), { recursive: true });
fs.writeFileSync(path.join(dir, '.git', 'refs', 'remotes', 'origin', 'main'), head + '\n');
test('classify reads the real version from the package.json version-path', () => {
const out = execFileSync('bun', [BIN, 'classify', '--base', 'main', '--version-path', pkgRel], { cwd: dir }).toString();
const parsed = JSON.parse(out);
expect(parsed.state).toBe('FRESH');
expect(parsed.baseVersion).toBe('0.99.2'); // was "0.0.0.0"
expect(parsed.currentVersion).toBe('0.99.2'); // was "0.0.0.0"
expect(parsed.pkgExists).toBe(true); // was false
});
test('write updates the package.json in place and creates no VERSION file', () => {
const out = execFileSync('bun', [BIN, 'write', '--version', '0.99.3', '--version-path', pkgRel], { cwd: dir }).toString();
expect(JSON.parse(out)).toEqual({ wrote: '0.99.3', versionPath: pkgRel, packageJson: true, packageLock: false });
const pkg = JSON.parse(fs.readFileSync(pkgAbs, 'utf-8'));
expect(pkg.version).toBe('0.99.3');
expect(pkg.scripts).toEqual({ dev: 'next dev' }); // rest of the file untouched
expect(pkg.name).toBe('frontend');
expect(fs.existsSync(path.join(dir, 'VERSION'))).toBe(false);
});
test('classify reports ALREADY_BUMPED after that write, not a drift state', () => {
const out = execFileSync('bun', [BIN, 'classify', '--base', 'main', '--version-path', pkgRel], { cwd: dir }).toString();
const parsed = JSON.parse(out);
expect(parsed.state).toBe('ALREADY_BUMPED');
expect(parsed.baseVersion).toBe('0.99.2');
expect(parsed.currentVersion).toBe('0.99.3');
});
test('repair is a no-op: there is no second file to drift from', () => {
const out = execFileSync('bun', [BIN, 'repair', '--version-path', pkgRel], { cwd: dir }).toString();
expect(JSON.parse(out).repaired).toBeNull();
});
test('write refuses a version-path that does not exist', () => {
let code = 0;
try {
execFileSync('bun', [BIN, 'write', '--version', '1.0.0', '--version-path', 'nope/package.json'], { cwd: dir, stdio: 'pipe' });
} catch (e: any) { code = e.status; }
expect(code).toBe(2);
});
});
/**
* #2462: cmdClassify's current-version read resolved the .gstack/version-path
* pin, but versionRel the repo-relative path fed to `git show
* origin/<base>:<path>` — came from the CLI flag alone. In a pinned repo with
* no --version-path flag, base and current therefore read DIFFERENT files:
* current from the pin, base from the root VERSION (which may not exist, so
* base always read 0.0.0.0 and every branch looked FRESH). The pin's
* repo-relative form now drives all three subcommands.
*/
describe('.gstack/version-path pin, no --version-path flag (#2462)', () => {
const mkPinned = (pinRel: string): string => {
const d = fs.mkdtempSync(path.join(os.tmpdir(), 'vbump-pin-'));
fs.mkdirSync(path.dirname(path.join(d, pinRel)), { recursive: true });
fs.mkdirSync(path.join(d, '.gstack'), { recursive: true });
fs.writeFileSync(path.join(d, '.gstack', 'version-path'), pinRel + '\n');
return d;
};
const commitBase = (d: string): void => {
execFileSync('git', ['init', '-q', '-b', 'main'], { cwd: d });
execFileSync('git', ['config', 'user.email', 't@e.com'], { cwd: d });
execFileSync('git', ['config', 'user.name', 't'], { cwd: d });
execFileSync('git', ['add', '-A'], { cwd: d });
execFileSync('git', ['commit', '-qm', 'base'], { cwd: d });
const head = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: d }).toString().trim();
fs.mkdirSync(path.join(d, '.git', 'refs', 'remotes', 'origin'), { recursive: true });
fs.writeFileSync(path.join(d, '.git', 'refs', 'remotes', 'origin', 'main'), head + '\n');
};
test('classify reads base AND current from the SAME pinned plain-text file', () => {
const pinRel = 'sub/VERSION';
const d = mkPinned(pinRel);
fs.writeFileSync(path.join(d, pinRel), '1.4.0.0\n');
commitBase(d);
// Move the pinned file past base — NO root VERSION file exists at all.
fs.writeFileSync(path.join(d, pinRel), '1.5.0.0\n');
const out = JSON.parse(execFileSync('bun', [BIN, 'classify', '--base', 'main'], { cwd: d }).toString());
// Before the fix: baseVersion read root VERSION → "0.0.0.0" and the
// branch misclassified as... current 1.5.0.0 vs base 0.0.0.0. The REAL
// base is the pinned file's committed value.
expect(out.baseVersion).toBe('1.4.0.0');
expect(out.currentVersion).toBe('1.5.0.0');
expect(out.state).toBe('ALREADY_BUMPED');
fs.rmSync(d, { recursive: true, force: true });
});
test('classify engages the pinned package.json JSON handling without a flag', () => {
const pinRel = 'frontend/package.json';
const d = mkPinned(pinRel);
fs.writeFileSync(path.join(d, pinRel), JSON.stringify({ name: 'f', version: '0.99.2' }, null, 2) + '\n');
commitBase(d);
const out = JSON.parse(execFileSync('bun', [BIN, 'classify', '--base', 'main'], { cwd: d }).toString());
// Before the fix: versionRel="VERSION" → the pinned JSON was read as raw
// text → currentVersion "0.0.0.0", pkgExists false, base from a
// nonexistent root VERSION.
expect(out.state).toBe('FRESH');
expect(out.baseVersion).toBe('0.99.2');
expect(out.currentVersion).toBe('0.99.2');
expect(out.pkgExists).toBe(true);
fs.rmSync(d, { recursive: true, force: true });
});
test('write honors the pin: updates the pinned package.json in place, no root VERSION invented', () => {
const pinRel = 'frontend/package.json';
const d = mkPinned(pinRel);
fs.writeFileSync(path.join(d, pinRel), JSON.stringify({ name: 'f', version: '0.99.2' }, null, 2) + '\n');
const out = JSON.parse(execFileSync('bun', [BIN, 'write', '--version', '0.99.3'], { cwd: d }).toString());
expect(out).toEqual({ wrote: '0.99.3', versionPath: pinRel, packageJson: true, packageLock: false });
expect(JSON.parse(fs.readFileSync(path.join(d, pinRel), 'utf-8')).version).toBe('0.99.3');
// Before the fix, write treated versionRel as "VERSION" and overwrote the
// pinned JSON file with a bare "0.99.3\n", destroying the manifest.
expect(fs.existsSync(path.join(d, 'VERSION'))).toBe(false);
fs.rmSync(d, { recursive: true, force: true });
});
test('repair honors the pin: pinned package.json is a no-op single source', () => {
const pinRel = 'frontend/package.json';
const d = mkPinned(pinRel);
fs.writeFileSync(path.join(d, pinRel), JSON.stringify({ name: 'f', version: '0.99.2' }, null, 2) + '\n');
const out = JSON.parse(execFileSync('bun', [BIN, 'repair'], { cwd: d }).toString());
expect(out.repaired).toBeNull();
fs.rmSync(d, { recursive: true, force: true });
});
test('--version-path flag still overrides the pin', () => {
const d = mkPinned('sub/VERSION');
fs.writeFileSync(path.join(d, 'sub', 'VERSION'), '1.0.0.0\n');
fs.writeFileSync(path.join(d, 'OTHER_VERSION'), '2.0.0.0\n');
commitBase(d);
const out = JSON.parse(
execFileSync('bun', [BIN, 'classify', '--base', 'main', '--version-path', 'OTHER_VERSION'], { cwd: d }).toString(),
);
expect(out.currentVersion).toBe('2.0.0.0');
expect(out.baseVersion).toBe('2.0.0.0');
fs.rmSync(d, { recursive: true, force: true });
});
});
describe('subdirectory manifest (no root package.json, #2531)', () => {
/**
* The layout this tool used to silently no-op on: the only Node package
* lives in web/, so join(cwd, "package.json") missed it, classify said
* pkgExists:false, and write touched VERSION alone leaving the manifest
* to be bumped by hand every release.
*/
const mk = (): string => {
const d = fs.mkdtempSync(path.join(os.tmpdir(), 'vbump-subdir-'));
fs.mkdirSync(path.join(d, 'web'));
fs.mkdirSync(path.join(d, '.gstack'));
fs.writeFileSync(path.join(d, '.gstack', 'package-json-path'), 'web/package.json\n');
fs.writeFileSync(path.join(d, 'VERSION'), '0.1.0.0\n');
return d;
};
test('write finds a pinned manifest and bumps it (npm-valid form)', () => {
const d = mk();
fs.writeFileSync(path.join(d, 'web', 'package.json'),
JSON.stringify({ name: 'w', version: '0.1.0' }, null, 2) + '\n');
const out = JSON.parse(execFileSync('bun', [BIN, 'write', '--version', '0.2.0.0'], { cwd: d }).toString());
expect(out.packageJson).toBe(true);
expect(out.packageJsonPath).toBe('web/package.json');
expect(out.packageJsonVersion).toBe('0.2.0');
expect(JSON.parse(fs.readFileSync(path.join(d, 'web', 'package.json'), 'utf-8')).version).toBe('0.2.0');
fs.rmSync(d, { recursive: true, force: true });
});
test('--package-json-path overrides the pin', () => {
const d = mk();
fs.mkdirSync(path.join(d, 'app'));
fs.writeFileSync(path.join(d, 'web', 'package.json'), JSON.stringify({ version: '0.1.0' }, null, 2) + '\n');
fs.writeFileSync(path.join(d, 'app', 'package.json'), JSON.stringify({ version: '0.1.0' }, null, 2) + '\n');
const out = JSON.parse(execFileSync('bun',
[BIN, 'write', '--version', '0.3.0.0', '--package-json-path', 'app/package.json'], { cwd: d }).toString());
expect(out.packageJsonPath).toBe('app/package.json');
expect(JSON.parse(fs.readFileSync(path.join(d, 'app', 'package.json'), 'utf-8')).version).toBe('0.3.0');
// the pinned one is untouched
expect(JSON.parse(fs.readFileSync(path.join(d, 'web', 'package.json'), 'utf-8')).version).toBe('0.1.0');
fs.rmSync(d, { recursive: true, force: true });
});
test('classify reads the pinned manifest and judges drift on the translated form', () => {
const d = mk();
fs.writeFileSync(path.join(d, 'web', 'package.json'),
JSON.stringify({ name: 'w', version: '0.1.0' }, null, 2) + '\n');
execFileSync('git', ['init', '-q', '-b', 'main'], { cwd: d });
execFileSync('git', ['config', 'user.email', 't@e.com'], { cwd: d });
execFileSync('git', ['config', 'user.name', 't'], { cwd: d });
execFileSync('git', ['add', '-A'], { cwd: d });
execFileSync('git', ['commit', '-qm', 'base'], { cwd: d });
const head = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: d }).toString().trim();
fs.mkdirSync(path.join(d, '.git', 'refs', 'remotes', 'origin'), { recursive: true });
fs.writeFileSync(path.join(d, '.git', 'refs', 'remotes', 'origin', 'main'), head + '\n');
const out = JSON.parse(execFileSync('bun', [BIN, 'classify', '--base', 'main'], { cwd: d }).toString());
// 0.1.0 IS the npm-valid translation of 0.1.0.0 — in sync, no drift.
expect(out.state).toBe('FRESH');
expect(out.pkgExists).toBe(true);
expect(out.pkgPath).toBe('web/package.json');
expect(out.expectedPkgVersion).toBe('0.1.0');
fs.rmSync(d, { recursive: true, force: true });
});
test('repair syncs the pinned manifest to the npm-valid form', () => {
const d = mk();
fs.writeFileSync(path.join(d, 'web', 'package.json'),
JSON.stringify({ name: 'w', version: '0.0.9' }, null, 2) + '\n');
const out = JSON.parse(execFileSync('bun', [BIN, 'repair'], { cwd: d }).toString());
expect(out).toEqual({ repaired: '0.1.0.0', packageJsonPath: 'web/package.json', packageJsonVersion: '0.1.0' });
expect(JSON.parse(fs.readFileSync(path.join(d, 'web', 'package.json'), 'utf-8')).version).toBe('0.1.0');
fs.rmSync(d, { recursive: true, force: true });
});
});
describe('npm-valid drift contract (decision 11)', () => {
test('a correctly-synced 3-component manifest is NOT read as drift', () => {
// Without the translation-aware comparison, 0.1.25 vs 0.1.25.0 reads as
// DRIFT forever and every classify returns a false positive.
expect(classifyState('0.1.25.0', '0.1.24.0', true, '0.1.25', '0.1.25')).toBe('ALREADY_BUMPED');
expect(classifyState('0.1.25.0', '0.1.25.0', true, '0.1.25', '0.1.25')).toBe('FRESH');
});
test('the pre-v1.67 1:1 four-digit mirror is grandfathered as in-sync', () => {
// Existing installs still carry package.json 1.66.0.0 next to VERSION
// 1.66.0.0. Flagging that as DRIFT_UNEXPECTED would hard-stop /ship on
// every repo on upgrade day; the next write migrates the manifest to the
// translated form instead.
expect(classifyState('1.66.0.0', '1.65.0.0', true, '1.66.0.0', '1.66.0')).toBe('ALREADY_BUMPED');
expect(classifyState('1.66.0.0', '1.66.0.0', true, '1.66.0.0', '1.66.0')).toBe('FRESH');
});
test('a genuinely diverged manifest still reads as drift', () => {
expect(classifyState('1.67.0.0', '1.66.0.0', true, '1.66.0', '1.67.0')).toBe('DRIFT_STALE_PKG');
expect(classifyState('1.66.0.0', '1.66.0.0', true, '9.9.9', '1.66.0')).toBe('DRIFT_UNEXPECTED');
});
});
describe('path containment: pins and flags cannot escape the repo', () => {
// .gstack/version-path and .gstack/package-json-path are repo-controlled
// content. A cloned repo pinning '../../victim.json' — or an in-repo
// symlink pointing outside — must never turn a bump into an arbitrary
// file overwrite outside the repository.
const outer = fs.mkdtempSync(path.join(os.tmpdir(), 'vbump-contain-'));
const dir = path.join(outer, 'repo');
const victim = path.join(outer, 'victim.json');
afterAll(() => { try { fs.rmSync(outer, { recursive: true, force: true }); } catch { /* noop */ } });
function runFail(args: string[]): { code: number; stderr: string } {
try {
execFileSync('bun', [BIN, ...args], { cwd: dir, stdio: 'pipe' });
return { code: 0, stderr: '' };
} catch (e: any) {
return { code: e.status, stderr: (e.stderr || '').toString() };
}
}
function resetRepo() {
fs.rmSync(dir, { recursive: true, force: true });
fs.mkdirSync(path.join(dir, '.gstack'), { recursive: true });
fs.writeFileSync(path.join(dir, 'VERSION'), '1.0.0.0\n');
fs.writeFileSync(victim, JSON.stringify({ version: '9.9.9' }, null, 2) + '\n');
}
test('a ../ escape in .gstack/version-path fails exit 2 and writes nothing', () => {
resetRepo();
fs.writeFileSync(path.join(dir, '.gstack', 'version-path'), '../victim.json\n');
const r = runFail(['write', '--version', '1.1.0.0']);
expect(r.code).toBe(2);
expect(r.stderr).toContain('outside the repository');
expect(JSON.parse(fs.readFileSync(victim, 'utf-8')).version).toBe('9.9.9');
});
test('an absolute path in .gstack/package-json-path fails exit 2', () => {
resetRepo();
fs.writeFileSync(path.join(dir, '.gstack', 'package-json-path'), victim + '\n');
const r = runFail(['write', '--version', '1.1.0.0']);
expect(r.code).toBe(2);
expect(r.stderr).toContain('outside the repository');
expect(JSON.parse(fs.readFileSync(victim, 'utf-8')).version).toBe('9.9.9');
});
test('an in-repo symlink pointing outside fails exit 2 and never follows', () => {
if (process.platform === 'win32') return; // symlink creation needs privileges there
resetRepo();
fs.symlinkSync(victim, path.join(dir, 'link.json'));
fs.writeFileSync(path.join(dir, '.gstack', 'version-path'), 'link.json\n');
const r = runFail(['write', '--version', '1.1.0.0']);
expect(r.code).toBe(2);
expect(r.stderr).toContain('outside the repository');
expect(JSON.parse(fs.readFileSync(victim, 'utf-8')).version).toBe('9.9.9');
});
test('classify refuses the same escapes (no read outside the repo)', () => {
resetRepo();
fs.writeFileSync(path.join(dir, '.gstack', 'version-path'), '../victim.json\n');
const r = runFail(['classify', '--base', 'main']);
expect(r.code).toBe(2);
expect(r.stderr).toContain('outside the repository');
});
test('a lockfile symlinked outside the repo is skipped with a warning, not written', () => {
if (process.platform === 'win32') return;
resetRepo();
const outerLock = path.join(outer, 'outer-lock.json');
fs.writeFileSync(outerLock, JSON.stringify({ version: '1.0.0', packages: { '': { version: '1.0.0' } } }, null, 2) + '\n');
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'x', version: '1.0.0' }, null, 2) + '\n');
fs.symlinkSync(outerLock, path.join(dir, 'package-lock.json'));
const res = execFileSync('bun', [BIN, 'write', '--version', '1.1.0.0'], { cwd: dir, stdio: 'pipe' });
expect(JSON.parse(res.toString()).packageLock).toBe(false);
expect(JSON.parse(fs.readFileSync(outerLock, 'utf-8')).version).toBe('1.0.0');
});
test('legitimate subdirectory pins still work (containment is not over-broad)', () => {
resetRepo();
fs.mkdirSync(path.join(dir, 'frontend'), { recursive: true });
fs.writeFileSync(path.join(dir, 'frontend', 'package.json'), JSON.stringify({ name: 'x', version: '1.0.0' }, null, 2) + '\n');
fs.writeFileSync(path.join(dir, '.gstack', 'version-path'), 'frontend/package.json\n');
const out = execFileSync('bun', [BIN, 'write', '--version', '1.1.0'], { cwd: dir }).toString();
expect(JSON.parse(out).wrote).toBe('1.1.0');
expect(JSON.parse(fs.readFileSync(path.join(dir, 'frontend', 'package.json'), 'utf-8')).version).toBe('1.1.0');
});
});
+14 -8
View File
@@ -126,7 +126,7 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
},
behavioral: 'external',
externalTest: 'test/skill-e2e-ship-section-loading.test.ts',
maxSkeletonBytes: 90_000,
maxSkeletonBytes: 90_800, // v1.67 wave + v1.66.1's evidence-ledger prose (merged): measured 90,333
minUnionBytes: 120_000,
mustContain: ['VERSION', 'CHANGELOG', 'review', 'merge', 'PR'],
// v1.58.5.0: pre-push-guard install (#2077) stacks on the shared first-run-guidance preamble.
@@ -157,7 +157,7 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
// v1.65 merge: provisional larger-of-both-waves budget; re-measured below.
// Fork port wave 2 (#703): the repo-doc-preference block in the design
// check grew every plan-review skeleton ~0.7KB. Measured values noted.
maxSkeletonBytes: 92_500, // v1.64+v1.65 merge: both waves' preamble growth; measured 92,004
maxSkeletonBytes: 93_000, // v1.67 fix wave: #2499 jq entry-resolution in the brain-sync preamble (~340B/skill) + wave doc additions; measured 92,531
minUnionBytes: 80_000,
mustContain: ['SCOPE EXPANSION', 'SELECTIVE EXPANSION', 'HOLD SCOPE', 'SCOPE REDUCTION'],
// Default-on Codex outside-voice (codexPreflight block + CODEX_MODE branch
@@ -181,7 +181,9 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
// v1.65 merge: provisional larger-of-both-waves budget; re-measured below.
// Fork port wave 2 (#703): the repo-doc-preference block in the design
// check grew every plan-review skeleton ~0.7KB. Measured values noted.
maxSkeletonBytes: 70_000, // measured 68,780
// #2499 project-scope MCP jq in the brain-sync block grew every tier-2+
// skeleton ~1.5KB (entry resolution emitted once per SKILL.md).
maxSkeletonBytes: 70_500, // measured 70,318
minUnionBytes: 70_000,
mustContain: ['Architecture', 'Code Quality', 'Test', 'Performance'],
// Cross-cutting preamble growth (v1.57.2.0 AUQ-failure prose fallback + the
@@ -236,7 +238,9 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
// v1.2.0 activation lift: first-run-guidance section in the shared preamble.
// Fork port wave 2 (#703): the repo-doc-preference block in the design
// check grew every plan-review skeleton ~0.7KB. Measured values noted.
maxSkeletonBytes: 82_000, // measured 80,493
// #2499 project-scope MCP jq in the brain-sync block grew every tier-2+
// skeleton ~1.5KB (entry resolution emitted once per SKILL.md).
maxSkeletonBytes: 82_500, // measured 82,031
minUnionBytes: 70_000,
mustContain: ['developer experience', 'Getting Started'],
// Default-on Codex outside-voice (codexPreflight block + CODEX_MODE branch
@@ -264,7 +268,9 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
// (judgment must be visible before the workflow directs the user to a
// vendor site), plus the #703 dual-write + repo-doc-preference block and
// the #538 opt-out + D1 evidence directive — ratio 1.104 measured.
maxSkeletonBytes: 101_000,
// #2499 project-scope MCP jq in the brain-sync block grew every tier-2+
// skeleton ~1.5KB (entry resolution emitted once per SKILL.md).
maxSkeletonBytes: 101_500, // measured 101,314
minUnionBytes: 70_000,
mustContain: ['design doc', 'problem statement'],
maxSizeRatio: 1.12,
@@ -285,7 +291,7 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
// +Conductor AUQ-default-prose rule + one-way/continuation safety in the
// always-loaded AskUserQuestion Format section.
// v1.2.0 activation lift: first-run-guidance section in the shared preamble.
maxSkeletonBytes: 56_500, // v1.64+v1.65 merge; measured 56,044
maxSkeletonBytes: 57_000, // v1.67 fix wave: #2499 preamble growth; measured 56,571
minUnionBytes: 55_000,
mustContain: ['CHANGELOG', 'Diataxis', 'coverage'],
// Two intentional additions stack on this small skill: the AUQ-failure prose
@@ -316,7 +322,7 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
// v1.65 merge: provisional larger-of-both-waves budget; re-measured below.
// v1.64.1.0: shared-preamble prose from the two parallel v1.64 waves lands
// the skeleton at 69,022 B; +~1 KB headroom.
maxSkeletonBytes: 70_000,
maxSkeletonBytes: 70_500, // v1.67 fix wave: #2499 preamble growth; measured 70,003
minUnionBytes: 72_000,
mustContain: ['Typography', 'Color', 'Aesthetic Direction'],
// Cross-cutting preamble growth (v1.57.2.0 AUQ-failure prose fallback ~2KB +
@@ -356,7 +362,7 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
// +Conductor AUQ-default-prose rule + one-way/continuation safety in the
// always-loaded AskUserQuestion Format section.
// v1.2.0 activation lift: first-run-guidance section in the shared preamble.
maxSkeletonBytes: 75_800, // v1.64+v1.65 merge; measured 75,364
maxSkeletonBytes: 76_400, // v1.67 fix wave: #2499 preamble growth; measured 75,891
minUnionBytes: 72_000,
mustContain: ['OWASP', 'STRIDE', 'daily', 'comprehensive', 'verif'],
// cso keeps its mode-dispatch + FP-filtering phases always-loaded, so the
+1 -1
View File
@@ -50,7 +50,7 @@ export const TOOL_COMPATIBILITY: Record<'claude' | 'gpt' | 'gemini', Record<Tool
Glob: false,
Grep: false,
AskUserQuestion: false,
WebSearch: true, // --enable web_search_cached
WebSearch: true, // -c 'web_search="cached"' (CODEX_WEB_SEARCH_FLAG, #2525)
WebFetch: false,
},
gemini: {
+13 -2
View File
@@ -125,10 +125,21 @@ describe('hermetic wiring tripwire', () => {
expect(configDir.startsWith(runRoot + path.sep)).toBe(true);
expect(configDir.startsWith(operatorClaude)).toBe(false);
const skillsDir = path.join(configDir, 'skills');
const repoRootReal = fs.realpathSync(ROOT) + path.sep;
for (const entry of fs.readdirSync(skillsDir)) {
const target = fs.readlinkSync(path.join(skillsDir, entry, 'SKILL.md'));
expect(target.startsWith(operatorClaude), `${entry}: symlink escapes to ${target}`).toBe(false);
expect(fs.realpathSync(target).startsWith(fs.realpathSync(ROOT) + path.sep), `${entry}: symlink outside repo: ${target}`).toBe(true);
const resolved = fs.realpathSync(target);
// Targets inside the live repo checkout are the blessed edge — exempt
// them BEFORE the operator-~/.claude ban. On the default global-git
// install the repo itself lives at ~/.claude/skills/gstack, so every
// CORRECT symlink carries the operatorClaude prefix and an unexempted
// ban can never pass (regression 2026-08-15: pristine v1.64.1.0 fails
// this test in any worktree under ~/.claude/skills/ and passes
// elsewhere — realpath both sides so a symlinked HOME can't dodge it).
if (!resolved.startsWith(repoRootReal)) {
expect(resolved.startsWith(operatorClaude), `${entry}: symlink escapes to ${target}`).toBe(false);
}
expect(resolved.startsWith(repoRootReal), `${entry}: symlink outside repo: ${target}`).toBe(true);
}
});
});
+28 -1
View File
@@ -3,7 +3,7 @@
* host-config-export.ts, and golden-file regression checks.
*/
import { describe, test, expect } from 'bun:test';
import { describe, test, expect, beforeAll } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';
import { validateHostConfig, validateAllConfigs, type HostConfig } from '../scripts/host-config';
@@ -421,6 +421,33 @@ describe('host-config-export.ts CLI', () => {
describe('golden-file regression', () => {
const GOLDEN_DIR = path.join(ROOT, 'test', 'fixtures', 'golden');
// #2532: the codex/factory goldens read gitignored .agents/ and .factory/
// artifacts that only gen-skill-docs.test.ts (a serial tree-mutating file)
// produces. On a clean clone — or when this file runs in isolation — those
// dirs don't exist and the goldens fail with ENOENT, an order dependency,
// not a regression. Self-provision: generate a host's artifacts iff its
// ship SKILL.md is missing. Existing artifacts are never overwritten here,
// so a genuinely stale artifact still fails the golden (that is the test's
// job; freshness enforcement lives in gen-skill-docs.test.ts).
beforeAll(() => {
const hostArtifacts: Array<[string, string]> = [
['codex', path.join(ROOT, '.agents', 'skills', 'gstack-ship', 'SKILL.md')],
['factory', path.join(ROOT, '.factory', 'skills', 'gstack-ship', 'SKILL.md')],
];
for (const [host, artifact] of hostArtifacts) {
if (fs.existsSync(artifact)) continue;
const result = Bun.spawnSync(['bun', 'run', 'scripts/gen-skill-docs.ts', '--host', host], {
cwd: ROOT,
});
if (result.exitCode !== 0) {
throw new Error(
`golden-file beforeAll: gen-skill-docs --host ${host} failed (exit ${result.exitCode}):\n`
+ result.stderr.toString(),
);
}
}
});
test('Claude ship skill matches golden baseline', () => {
const golden = fs.readFileSync(path.join(GOLDEN_DIR, 'claude-ship-SKILL.md'), 'utf-8');
const current = fs.readFileSync(path.join(ROOT, 'ship', 'SKILL.md'), 'utf-8');
+22 -13
View File
@@ -3,17 +3,22 @@ import * as fs from 'fs';
import * as path from 'path';
// Static tripwire (free tier, runs on every PR): the private-API ObjC touch
// bridge MUST compile out of Release builds. That safety property has two
// load-bearing halves, and this test pins both against regression:
// bridge MUST compile out of Release builds. v1.67.0.0 landed the enforcement
// (measured on a real app: `nm -j` on a Release binary previously returned 15
// DebugBridge symbols incl. IOHIDEventCreateDigitizer — a Guideline 2.5.1
// private-API exposure); this tripwire pins its two load-bearing halves
// against regression:
//
// 1. DebugBridgeTouch.m's body is gated `#if TARGET_OS_IOS && DEBUG` — NOT a
// bare `#if TARGET_OS_IOS` (which shipped the private symbols in Release
// iOS builds; PR #2264 claimed they were compiled out but the guard was
// platform-only).
// 1. DebugBridgeTouch.m short-circuits Release FIRST: `#if !defined(DEBUG)`
// emits an empty translation unit, and the implementation lives behind
// `#elif TARGET_OS_IOS`. A revert to a bare platform-only `#if
// TARGET_OS_IOS` gate (the original regression) ships the private
// symbols in Release again.
// 2. The DebugBridgeTouch target in Package.swift carries a cSettings
// DEBUG define scoped to the debug configuration — without it, `#if DEBUG`
// is false even in Debug and the bridge silently breaks in the case it
// exists to serve.
// DEBUG define scoped to the debug configuration — SwiftPM's implicit
// DEBUG for C-family targets is not guaranteed, and without the define
// `#if DEBUG` is false even in Debug, silently breaking the bridge in
// the one case it exists to serve.
//
// The full proof — an iOS-SDK Release build asserting `nm`/`strings` of the
// built binary contain none of _touchesEvent / IOHIDEventCreateDigitizer* /
@@ -35,11 +40,15 @@ const PACKAGE_MANIFESTS = [
describe('DebugBridgeTouch Release compile-out guard', () => {
for (const rel of TOUCH_SOURCES) {
test(`${rel} gates the body on TARGET_OS_IOS && DEBUG`, () => {
test(`${rel} short-circuits Release before any platform gate`, () => {
const src = fs.readFileSync(path.join(ROOT, rel), 'utf-8');
// The primary body guard must be the DEBUG-qualified form.
expect(src).toContain('#if TARGET_OS_IOS && DEBUG');
// And must NOT carry a bare platform-only guard as the body gate — that
// Release short-circuit first, implementation behind the elif.
expect(src).toContain('#if !defined(DEBUG)');
expect(src).toContain('#elif TARGET_OS_IOS');
// The Release branch must come BEFORE the platform branch — order is the
// property (a platform-first gate compiled private API into Release).
expect(src.indexOf('#if !defined(DEBUG)')).toBeLessThan(src.indexOf('#elif TARGET_OS_IOS'));
// And no bare platform-only guard may reappear as the body gate — that
// was the exact regression (private API shipped in Release).
expect(src).not.toMatch(/^#if TARGET_OS_IOS$/m);
});
+1 -1
View File
@@ -17,7 +17,7 @@ import { describe, test, expect } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';
const ROOT = path.resolve(new URL(import.meta.url).pathname, '..', '..');
const ROOT = path.resolve(import.meta.path, '..', '..');
/** A PTY send whose payload starts with a slash command (`/name`, optionally
* followed by `\r`, whitespace, or the closing quote). A second slash right
@@ -0,0 +1,62 @@
/**
* Question Tuning registry path must be absolute (#2489).
*
* The preamble told agents to choose question_id from a RELATIVE
* `scripts/question-registry.ts` which never resolves from a user's
* project cwd (the file lives only under the gstack install root). The
* lookup silently failed and agents fabricated ids via the {skill}-{slug}
* fallback (one observed /plan-eng-review session: 21/21 unregistered).
*
* The resolver now interpolates the installed path the same way ${bin}
* paths are interpolated: ctx.paths.skillRoot (a `~`-rooted path on Claude,
* $GSTACK_ROOT on env-var hosts).
*/
import { describe, test, expect } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';
import { HOST_PATHS } from '../scripts/resolvers/types';
import type { TemplateContext } from '../scripts/resolvers/types';
import { generateQuestionTuning } from '../scripts/resolvers/question-tuning';
const ROOT = path.join(import.meta.dir, '..');
function makeCtx(host: 'claude' | 'codex'): TemplateContext {
return {
skillName: 'test-skill',
tmplPath: 'test.tmpl',
host,
paths: HOST_PATHS[host],
preambleTier: 2,
};
}
describe('question-tuning registry path is absolute (#2489)', () => {
test('claude host renders the installed registry path', () => {
const out = generateQuestionTuning(makeCtx('claude'));
expect(out).toContain('`~/.claude/skills/gstack/scripts/question-registry.ts`');
});
test('env-var host renders $GSTACK_ROOT-anchored registry path', () => {
const out = generateQuestionTuning(makeCtx('codex'));
expect(out).toContain('`$GSTACK_ROOT/scripts/question-registry.ts`');
});
test('no host renders a bare relative registry path', () => {
for (const host of ['claude', 'codex'] as const) {
const out = generateQuestionTuning(makeCtx(host));
// A backtick immediately before `scripts/` means the path renders
// relative — exactly the shape that never resolves from a project cwd.
expect(out).not.toContain('`scripts/question-registry.ts`');
}
});
test('the interpolated path points at a file that exists in the install tree', () => {
expect(fs.existsSync(path.join(ROOT, 'scripts', 'question-registry.ts'))).toBe(true);
});
test('rendered SKILL.md carries the absolute path', () => {
const rendered = fs.readFileSync(path.join(ROOT, 'plan-eng-review', 'SKILL.md'), 'utf-8');
expect(rendered).toContain('~/.claude/skills/gstack/scripts/question-registry.ts');
expect(rendered).not.toContain('`scripts/question-registry.ts`');
});
});
+46
View File
@@ -189,6 +189,52 @@ describe("MEDIUM demoted credential-shaped patterns (TENSION-1)", () => {
expect(ids("API_KEY=${MY_VAR}")).not.toContain("env.kv");
});
// #1946 gap 3: the uppercase-`=`-only shape made lowercase and YAML/JSON
// colon assignments invisible — the exact config shapes people actually
// push. Each closed detection fail-open gets a pinned case.
test("env.kv fires on lowercase = assignment (#1946)", () => {
expect(ids("api_key=8Fk2pQ9vXz4wL7mN3rT6yB1cD5eG0hJ")).toContain("env.kv");
});
test("env.kv fires on YAML colon assignment (#1946)", () => {
expect(ids("password: 8Fk2pQ9vXz4wL7mN3rT6yB1cD5eG0hJ")).toContain("env.kv");
});
test("env.kv fires on quoted JSON key colon assignment (#1946)", () => {
expect(ids('"apiKey": "8Fk2pQ9vXz4wL7mN3rT6yB1cD5eG0hJ"')).toContain("env.kv");
});
test("env.kv colon/lowercase forms stay entropy-gated and placeholder-safe", () => {
expect(ids("password: changeme")).not.toContain("env.kv");
expect(ids("apiKey: YOUR_API_KEY_HERE")).not.toContain("env.kv");
expect(ids("api_key=${MY_VAR}")).not.toContain("env.kv");
});
// T1 calibration: the zero-or-more-prefix net matched ANY identifier ending
// in a suffix, so ordinary code (`cacheKey: <entropic id>`) hit a MEDIUM
// confirm prompt. Name shape must be credential-semantic to count.
test("env.kv ignores non-credential names ending in a suffix (entropic values)", () => {
const v = "8Fk2pQ9vXz4wL7mN3rT6yB1cD5eG0hJ";
expect(ids(`cacheKey: ${v}`)).not.toContain("env.kv");
expect(ids(`sortKey: ${v}`)).not.toContain("env.kv");
expect(ids(`partitionKey: ${v}`)).not.toContain("env.kv");
expect(ids(`hotkey: ${v}`)).not.toContain("env.kv");
expect(ids(`monkey: ${v}`)).not.toContain("env.kv");
expect(ids(`idempotencyKey: ${v}`)).not.toContain("env.kv");
});
test("env.kv still fires on every credential-shaped name form", () => {
const v = "8Fk2pQ9vXz4wL7mN3rT6yB1cD5eG0hJ";
expect(ids(`api_key=${v}`)).toContain("env.kv"); // (i) separator
expect(ids(`API_KEY=${v}`)).toContain("env.kv"); // (i) + ALL-CAPS
expect(ids(`x-access-key: ${v}`)).toContain("env.kv"); // (i) dash separator
expect(ids(`key: ${v}`)).toContain("env.kv"); // (ii) bare suffix
expect(ids(`APIKEY=${v}`)).toContain("env.kv"); // (iii) ALL-CAPS compound
expect(ids(`apiKey: ${v}`)).toContain("env.kv"); // (iv) credential camel
expect(ids(`authToken: ${v}`)).toContain("env.kv"); // (iv) credential camel
expect(ids(`clientSecret: ${v}`)).toContain("env.kv"); // (iv) credential camel
});
test("env.kv stays MEDIUM (calibration: generic net, not a blocker)", () => {
const f = scan("api_key=8Fk2pQ9vXz4wL7mN3rT6yB1cD5eG0hJ", { repoVisibility: "private" })
.findings.find((x) => x.id === "env.kv");
expect(f?.tier).toBe("MEDIUM");
});
// #1946 — Bearer is the most FP-prone shape in the wave: docs and examples
// are full of "Authorization: Bearer <token>". MEDIUM + header proximity +
// the env.kv entropy recipe keep it calibrated.
@@ -0,0 +1,77 @@
/**
* pii.phone.e164 vs county tax-map parcel IDs (APNs).
*
* A parcel ID reads as a national-format phone number to the e164 pattern
* the same collision class as the digit-only UUID `insideUuid` already guards.
* Land, title and property-tax repos carry these by the hundred, so the noise
* is not incidental; it arrives on every branch that touches the domain.
*
* The guard has to be narrow, so this file pins BOTH directions: parcels stay
* clean, and every real phone shape stays flagged. The negative controls are
* the point a guard that exempted long digit runs wholesale would pass the
* "parcels clean" half and quietly gut the pattern.
*/
import { describe, test, expect } from "bun:test";
import { scan } from "../lib/redact-engine";
import { looksLikeParcelId } from "../lib/redact-patterns";
const flagsPhone = (s: string): boolean =>
scan(s, { repoVisibility: "private" }).findings.some((f) => f.id === "pii.phone.e164");
describe("pii.phone.e164 — real phone numbers stay flagged", () => {
const REAL_PHONES: [string, string][] = [
["US dashed", "call 415-555-0123 now"],
["US parens", "phone: (415) 555-0123"],
["US dotted", "p 415.555.0123"],
["E.164 US", "tel: +14155550123"],
["E.164 US spaced", "contact +1 415 555 0123"],
["E.164 UK", "ring +44 20 7946 0958"],
["E.164 DE", "fon +49 30 901820"],
["E.164 PT 12-digit", "reach +351912345678"],
["bare 11-digit", "operator 14155550123 ext"],
];
for (const [label, input] of REAL_PHONES) {
test(label, () => {
expect(flagsPhone(input)).toBe(true);
});
}
});
describe("pii.phone.e164 — parcel IDs are not phone numbers", () => {
test("dotted APN is exempt on shape alone", () => {
expect(flagsPhone(' parcel_id: "12-3456789.000",')).toBe(false);
});
test("a normalized APN is exempt when paired with its punctuated form", () => {
expect(
flagsPhone(' parcel_id: "12-3456789.000",\n norm: "123456789000",'),
).toBe(false);
});
test("8-digit middle is still an APN", () => {
expect(flagsPhone('apn "30-00414123.0001"')).toBe(false);
});
});
describe("pii.phone.e164 — the guard stays narrow", () => {
/**
* The load-bearing control. A bare digit run is phone-shaped in isolation, so
* it may only be exempted by EVIDENCE a punctuated APN in the surrounding
* window. With no such twin, the finding must survive. If this ever goes
* green-by-exemption, the guard has become a blanket hole in the pattern.
*/
test("a bare digit run with no punctuated APN nearby is still flagged", () => {
expect(flagsPhone('norm: "123456789000",')).toBe(true);
});
test("hyphen-only APN variants are NOT exempted by shape", () => {
// 22-0001-000 is genuinely phone-shaped; only the dotted form earns a
// shape-based pass. This one may only be cleared by the evidence tier.
expect(looksLikeParcelId("22-0001-000", /(.*)/.exec("22-0001-000")!)).toBe(false);
});
test("evidence pairing requires an exact digit match, not a prefix", () => {
// A near-miss APN in the window must not clear a different digit run.
expect(flagsPhone(' parcel_id: "12-3456789.000",\n other: "999888777666",')).toBe(true);
});
});
+131 -23
View File
@@ -44,6 +44,24 @@ function runHook(
return { code: r.status ?? 0, stderr: r.stderr ?? "" };
}
/**
* Env override that puts `binDir` first on the search path, for tests that
* shadow a real binary with a stub.
*
* Two portability details, both of which a hardcoded `PATH: "dir:" + ...`
* gets wrong (mirrors `prependPath` in test/gstack-brain-context-load.test.ts):
* - The separator is `;` on Windows, not `:`. Using the literal produces one
* unparseable entry, so the stub is never found and the REAL binary runs
* a test that silently passes through rather than failing loudly.
* - Windows env keys are case-insensitive and commonly spelled `Path`. Adding
* a second `PATH` key alongside an inherited `Path` leaves which one wins up
* to the spawn implementation, so reuse whichever key already exists.
*/
function prependPath(binDir: string): Record<string, string> {
const pathKey = Object.keys(process.env).find((k) => k.toLowerCase() === "path") || "PATH";
return { [pathKey]: `${binDir}${path.delimiter}${process.env[pathKey] || ""}` };
}
const ZERO = "0000000000000000000000000000000000000000";
// Assembled at runtime so the LITERAL never appears in a pushed diff — the
@@ -147,30 +165,44 @@ describe("fail closed on unscannable diffs (#1946)", () => {
expect(stderr).not.toContain("could not compute the pushed diff");
});
test("a diff killed by a signal (null status — the maxBuffer/kill class) BLOCKS", () => {
// Stub git: probes delegate to the real git; the diff invocation kills
// itself, producing spawnSync status === null. This is the exact branch
// gitStrict's docstring names (oversized-diff overflow is delivered the
// same way) — pre-landing review flagged it as untested.
const realGit = Bun.which("git") || "/usr/bin/git";
const stubDir = fs.mkdtempSync(path.join(os.tmpdir(), "prepush-stubgit-"));
try {
const stub = `#!/bin/sh\nif [ "$1" = "diff" ]; then kill -KILL $$; fi\nexec "${realGit}" "$@"\n`;
fs.writeFileSync(path.join(stubDir, "git"), stub);
fs.chmodSync(path.join(stubDir, "git"), 0o755);
// POSIX-only, for two independent reasons. `spawnSync` reports
// `status === null` only when a child dies from a signal, and Windows has no
// equivalent — a force-killed process surfaces a non-zero exit code there — so
// the branch this test names is unreachable. The stub below is also a
// `#!/bin/sh` file named `git`, which Windows will not execute at all, since
// process creation resolves commands through PATHEXT (.exe/.cmd/.bat) and
// ignores the shebang. A Windows variant would have to assert the non-zero
// exit path instead, i.e. a different branch than the name claims, so it is
// skipped rather than rewritten. Gate style follows
// test/session-runner-timeout.test.ts and test/setup-emoji-font.test.ts.
test.skipIf(process.platform === "win32")(
"a diff killed by a signal (null status — the maxBuffer/kill class) BLOCKS",
() => {
// Stub git: probes delegate to the real git; the diff invocation kills
// itself, producing spawnSync status === null. This is the exact branch
// gitStrict's docstring names (oversized-diff overflow is delivered the
// same way) — pre-landing review flagged it as untested.
const realGit = Bun.which("git") || "/usr/bin/git";
const stubDir = fs.mkdtempSync(path.join(os.tmpdir(), "prepush-stubgit-"));
try {
const stub = `#!/bin/sh\nif [ "$1" = "diff" ]; then kill -KILL $$; fi\nexec "${realGit}" "$@"\n`;
fs.writeFileSync(path.join(stubDir, "git"), stub);
fs.chmodSync(path.join(stubDir, "git"), 0o755);
const base = git(["rev-parse", "HEAD"]);
const head = commit("clean.txt", "clean content\n", "clean commit");
const { code, stderr } = runHook(`refs/heads/main ${head} refs/heads/main ${base}\n`, {
PATH: `${stubDir}:${process.env.PATH}`,
});
expect(code).toBe(1);
expect(stderr).toContain("could not compute the pushed diff");
expect(stderr).toContain("GSTACK_REDACT_PREPUSH=skip");
} finally {
fs.rmSync(stubDir, { recursive: true, force: true });
}
});
const base = git(["rev-parse", "HEAD"]);
const head = commit("clean.txt", "clean content\n", "clean commit");
const { code, stderr } = runHook(
`refs/heads/main ${head} refs/heads/main ${base}\n`,
prependPath(stubDir),
);
expect(code).toBe(1);
expect(stderr).toContain("could not compute the pushed diff");
expect(stderr).toContain("GSTACK_REDACT_PREPUSH=skip");
} finally {
fs.rmSync(stubDir, { recursive: true, force: true });
}
},
);
});
describe("install UX surfaces (#1946 / eng review D3+D10)", () => {
@@ -189,6 +221,82 @@ describe("install UX surfaces (#1946 / eng review D3+D10)", () => {
expect(tmpl).toContain(".redact-prepush-prompted");
expect(tmpl).toContain("redact_prepush_hook");
});
// #1946 / maintainer decision 6: setup asks ONCE for consent on a real TTY,
// records the answer to the existing redact_prepush_hook key, and keeps the
// hint-only posture everywhere else. Default stays FALSE; setup never
// installs the hook itself (the assertion above pins that).
describe("one-time consent prompt in setup (#1946, decision 6)", () => {
const setup = fs.readFileSync(path.join(ROOT, "setup"), "utf8");
const block = setup.slice(setup.indexOf("# ─── Redact pre-push guard consent"));
test("prompt is gated on key ABSENCE and a real TTY, with a timed default-N read", () => {
expect(block).toContain("grep -q '^redact_prepush_hook:'");
expect(block).toContain('[ -t 0 ] && [ -t 1 ]');
expect(block).toContain("[y/N]");
expect(block).toContain('read -t "$_REDACT_PROMPT_TIMEOUT"');
});
test("an explicit answer persists true/false; timeout persists NOTHING", () => {
expect(block).toContain("set redact_prepush_hook true");
expect(block).toContain("set redact_prepush_hook false");
// The timeout branch must not write the key (a silent decline would
// permanently suppress the ask without the user ever seeing it). The
// branch's hint TEXT mentions the command; the executable invocation is
// the quoted "$GSTACK_CONFIG" form.
const timeoutBranch = block.slice(block.indexOf("*)"), block.indexOf("esac"));
expect(timeoutBranch).not.toContain('"$GSTACK_CONFIG" set redact_prepush_hook');
});
test("non-interactive setup keeps the hint-only posture (no prompt, no key write)", () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), "gstack-consent-"));
try {
const script = [
"QUIET=0",
'log() { echo "$@"; }',
`GSTACK_CONFIG="${path.join(ROOT, "bin", "gstack-config")}"`,
block,
].join("\n");
const r = spawnSync("bash", ["-c", script], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"], // stdin not a TTY
env: { ...process.env, GSTACK_HOME: home },
timeout: 15_000,
});
expect(r.status).toBe(0);
expect(r.stdout).toContain("Tip:");
expect(r.stdout).not.toContain("[y/N]");
const cfg = path.join(home, "config.yaml");
const cfgText = fs.existsSync(cfg) ? fs.readFileSync(cfg, "utf8") : "";
expect(cfgText).not.toContain("redact_prepush_hook");
} finally {
fs.rmSync(home, { recursive: true, force: true });
}
});
test("a recorded answer is never re-asked (key present → silent)", () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), "gstack-consent-set-"));
try {
fs.writeFileSync(path.join(home, "config.yaml"), "redact_prepush_hook: false\n");
const script = [
"QUIET=0",
'log() { echo "$@"; }',
`GSTACK_CONFIG="${path.join(ROOT, "bin", "gstack-config")}"`,
block,
].join("\n");
const r = spawnSync("bash", ["-c", script], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
env: { ...process.env, GSTACK_HOME: home },
timeout: 15_000,
});
expect(r.status).toBe(0);
expect(r.stdout.trim()).toBe("");
} finally {
fs.rmSync(home, { recursive: true, force: true });
}
});
});
});
describe("escape valve", () => {
@@ -0,0 +1,154 @@
/**
* gstack-redact-prepush the REBASED FORCE-PUSH shape (#2573).
*
* After `git rebase origin/main`, the remote tip of the feature branch still
* exists locally (it is the pre-rebase tip) but is no longer an ancestor of
* HEAD. The old `remoteSha..localSha` range therefore swept in every upstream
* commit rebased onto content already published and already scanned when it
* reached the remote. Reported as #2573: a 23-commit branch rebased onto a
* main that advanced 23 commits scanned 1.14 MiB instead of 0.27 MiB, tripped
* the engine's 1 MiB cap, and blocked the push with engine.input_too_large
* a HIGH that was the engine saying it never ran, not a finding.
*
* The catch-up-merge narrowing (`rev-list localSha --not remoteSha --remotes`)
* covers this shape too: the upstream commits are reachable from origin/main's
* remote-tracking ref, which exists by construction you cannot have rebased
* onto origin/main without it. These tests PROVE that, end-to-end through the
* actual hook binary with the real pre-push stdin protocol, in both
* directions: upstream content is not re-scanned (a HIGH-shaped credential
* someone else already published does not block a clean rebased push), and
* narrowing does not narrow coverage (a HIGH in a rebased commit of our own
* still blocks).
*
* Analyzed non-gap, recorded for the next reader: a rebased force-push where
* the upstream commits sit in NO remote-tracking ref cannot arise from the
* standard flow rebasing onto origin/<branch> requires the tracking ref,
* and rebasing onto a purely local branch means the "upstream" commits were
* never published, so scanning them is correct, not a false positive.
*/
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
import * as fs from "fs";
import * as os from "os";
import * as path from "path";
import { spawnSync } from "child_process";
const PREPUSH = path.resolve(import.meta.dir, "..", "bin", "gstack-redact-prepush");
let repo: string;
let remote: string;
function git(args: string[], cwd = repo): string {
const r = spawnSync("git", args, { cwd, encoding: "utf8" });
if (r.status !== 0) throw new Error(`git ${args.join(" ")}\n${r.stderr}`);
return r.stdout?.trim() ?? "";
}
function commit(file: string, content: string, msg: string): string {
fs.mkdirSync(path.dirname(path.join(repo, file)), { recursive: true });
fs.writeFileSync(path.join(repo, file), content);
git(["add", file]);
git(["commit", "-q", "-m", msg]);
return git(["rev-parse", "HEAD"]);
}
function runHook(stdinLines: string): { code: number; stderr: string } {
const r = spawnSync("bun", [PREPUSH], {
cwd: repo,
input: Buffer.from(stdinLines),
encoding: "utf8",
env: { ...process.env },
});
return { code: r.status ?? 0, stderr: r.stderr ?? "" };
}
// Assembled at runtime so the LITERAL never appears in a pushed diff — the
// repo's own pre-push scanner (correctly) blocks live-format AWS key shapes.
const FAKE_AWS_KEY = ["AKIA", "1234567890ABCDEF"].join("");
/**
* Build the #2573 shape:
* 1. feature branch pushed remote + tracking ref hold the pre-rebase tip
* 2. main advances with someone else's already-published HIGH-shaped
* fixture, pushed and fetched
* 3. feature rebases onto origin/main
* Returns the pre-rebase tip (what git hands the hook as remoteSha on the
* force-push) it still EXISTS locally but is no longer an ancestor of HEAD.
*/
function buildRebasedForcePush(): { preRebaseTip: string } {
git(["checkout", "-q", "-b", "feature"]);
commit("mine.ts", "export const mine = 1;\n", "my clean work");
git(["push", "-q", "-u", "origin", "feature"]);
const preRebaseTip = git(["rev-parse", "HEAD"]);
// Someone else lands a HIGH-shaped placeholder on main. It is published:
// pushed to the remote, fetched into origin/main.
git(["checkout", "-q", "main"]);
commit("fixtures/foreign.txt", `key ${FAKE_AWS_KEY}\n`, "someone else's fixture");
git(["push", "-q", "origin", "main"]);
git(["fetch", "-q", "origin"]);
git(["checkout", "-q", "feature"]);
git(["rebase", "-q", "origin/main"]);
return { preRebaseTip };
}
beforeEach(() => {
repo = fs.mkdtempSync(path.join(os.tmpdir(), "prepush-rebase-"));
remote = fs.mkdtempSync(path.join(os.tmpdir(), "prepush-rebase-remote-"));
git(["init", "-q", "--bare", "-b", "main"], remote);
git(["init", "-q", "-b", "main"]);
git(["config", "user.email", "t@example.com"]);
git(["config", "user.name", "T"]);
commit("README.md", "seed\n", "seed");
git(["remote", "add", "origin", remote]);
git(["push", "-q", "-u", "origin", "main"]);
});
afterEach(() => {
fs.rmSync(repo, { recursive: true, force: true });
fs.rmSync(remote, { recursive: true, force: true });
});
describe("rebased force-push does not re-scan upstream commits (#2573)", () => {
test("fixture sanity: the old two-dot range WOULD have swept in the upstream credential", () => {
const { preRebaseTip } = buildRebasedForcePush();
// The rebased tip exists locally and is NOT an ancestor of HEAD — the
// exact condition #2573 identified as the untested third branch.
expect(git(["cat-file", "-t", preRebaseTip])).toBe("commit");
const isAncestor = spawnSync("git", ["merge-base", "--is-ancestor", preRebaseTip, "HEAD"], { cwd: repo });
expect(isAncestor.status).not.toBe(0);
// What the OLD range would scan: upstream's published fixture included.
const oldDiff = git(["diff", "--unified=0", `${preRebaseTip}..HEAD`]);
expect(oldDiff).toContain(FAKE_AWS_KEY);
});
test("a clean rebased force-push passes: the published upstream credential does not block", () => {
const { preRebaseTip } = buildRebasedForcePush();
const head = git(["rev-parse", "HEAD"]);
const { code, stderr } = runHook(`refs/heads/feature ${head} refs/heads/feature ${preRebaseTip}\n`);
expect(stderr).not.toContain("BLOCKED");
expect(code).toBe(0);
});
test("narrowing does not narrow coverage: a HIGH in a REBASED commit of our own still blocks", () => {
const { preRebaseTip } = buildRebasedForcePush();
commit("leak.txt", `key ${FAKE_AWS_KEY}\n`, "oops, my own leak");
const head = git(["rev-parse", "HEAD"]);
const { code, stderr } = runHook(`refs/heads/feature ${head} refs/heads/feature ${preRebaseTip}\n`);
expect(code).toBe(1);
expect(stderr).toContain("BLOCKED");
});
test("the scanned commit set is exactly the rebased own commits, none of upstream's", () => {
// Pin the range arithmetic itself (the #2573 measurement, in miniature):
// the narrowed set excludes every commit reachable from a remote-tracking
// ref, so the 1.14 MiB-vs-0.27 MiB pathology cannot recur — scan size is
// proportional to OUR commits, not to how busy main was.
const { preRebaseTip } = buildRebasedForcePush();
const narrowed = git(["rev-list", "HEAD", "--not", preRebaseTip, "--remotes"])
.split("\n").filter(Boolean);
expect(narrowed).toHaveLength(1); // the rebased copy of "my clean work"
const subject = git(["log", "-1", "--format=%s", narrowed[0]]);
expect(subject).toBe("my clean work");
});
});
+276
View File
@@ -0,0 +1,276 @@
/**
* gstack-redact-prepush WHICH commits get scanned.
*
* `remoteSha..localSha` is "everything new on this branch", not "everything new
* to the remote". Merge origin/main into a feature branch and every commit main
* gained since the last push becomes an added line: already published, already
* scanned, not this push's doing. That produces false HIGH findings on other
* people's merged fixtures, and blows the engine's size cap on busy repos.
*
* These tests build real repositories on disk, because the behaviour under test
* IS the git plumbing a mocked `git` would test the mock. Each asserts on the
* added-line text the hook would scan.
*
* The direction that matters most is the LAST describe block: narrowing the
* range must not narrow COVERAGE. A secret in a new commit, or introduced while
* resolving a merge, still has to be seen.
*/
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
import { spawnSync } from "child_process";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs";
import { tmpdir } from "os";
import { dirname, join } from "path";
let dir: string;
const run = (args: string[], cwd = dir): string => {
const r = spawnSync("git", args, { cwd, encoding: "utf8" });
if (r.status !== 0) throw new Error(`git ${args.join(" ")}\n${r.stderr}`);
return r.stdout ?? "";
};
const commit = (file: string, body: string, msg: string, cwd = dir) => {
mkdirSync(dirname(join(cwd, file)), { recursive: true });
writeFileSync(join(cwd, file), body);
run(["add", file], cwd);
run(["commit", "-q", "-m", msg], cwd);
};
/**
* The range the fixed hook uses: commits reachable from HEAD and from no
* remote-tracking ref, each diffed alone with --cc.
*/
function addedLinesFromNewCommits(cwd: string): string {
const listed = run(["rev-list", "HEAD", "--not", "--remotes"], cwd).trim();
if (!listed) return "";
const out: string[] = [];
for (const sha of listed.split("\n").filter(Boolean)) {
out.push(run([
"show", "--unified=0", "--no-color", "--no-ext-diff", "--no-textconv",
"--cc", "--format=", sha,
], cwd));
}
return out.join("\n");
}
/** The old behaviour, for contrast. */
function addedLinesFromTwoDot(cwd: string, remoteRef: string): string {
return run([
"diff", "--unified=0", "--no-color", "--no-ext-diff", "--no-textconv",
`${remoteRef}..HEAD`,
], cwd);
}
const addedOnly = (diff: string): string =>
diff.split("\n")
.filter((l) => l.startsWith("+") && !l.startsWith("+++"))
.join("\n");
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "gstack-prepush-"));
run(["init", "-q", "-b", "main"]);
run(["config", "user.email", "t@example.com"]);
run(["config", "user.name", "T"]);
commit("README.md", "seed\n", "seed");
});
afterEach(() => rmSync(dir, { recursive: true, force: true }));
// Live-FORMAT fakes assembled at runtime so the pushed diff of THIS file never
// contains a credential-shaped literal — the pre-push guard scans diff bytes,
// and the v1.64 wave's dogfood rule stands: assemble the fixture, never bypass
// the guard. The runtime strings stay live-format for the hook under test.
const FAKE_DB_URL = ["postgresql://user:pass", "@db.example.com/x"].join("");
const FAKE_AWS_SECRETX = ["AKIA", "IOSFODNN7SECRETX"].join("");
const FAKE_AWS_RESOLV = ["AKIA", "IOSFODNN7RESOLV"].join("");
const FAKE_AWS_NOREMOT = ["AKIA", "IOSFODNN7NOREMOT"].join("");
/** Give the repo an "origin" whose main carries a fixture we did not write. */
function setUpRemoteWithForeignFixture(): void {
const remote = mkdtempSync(join(tmpdir(), "gstack-prepush-remote-"));
run(["init", "-q", "--bare", "-b", "main"], remote);
run(["remote", "add", "origin", remote]);
run(["push", "-q", "origin", "main"]);
// Someone else lands a placeholder connection string on main.
commit("fixtures/db.ts", `export const URL = "${FAKE_DB_URL}";\n`, "someone else's fixture");
run(["push", "-q", "origin", "main"]);
run(["fetch", "-q", "origin"]);
}
describe("a catch-up merge does not re-scan already-published content", () => {
test("the foreign fixture is absent from the scanned text", () => {
setUpRemoteWithForeignFixture();
// Branch from BEFORE that fixture, then merge main in to catch up.
run(["checkout", "-q", "-b", "feature", "HEAD~1"]);
commit("mine.ts", "export const mine = 1;\n", "my work");
run(["merge", "-q", "--no-edit", "main"]);
const scanned = addedOnly(addedLinesFromNewCommits(dir));
expect(scanned).toContain("export const mine = 1;");
expect(scanned).not.toContain(FAKE_DB_URL);
});
test("the old two-dot range DID re-scan it — this is the bug", () => {
setUpRemoteWithForeignFixture();
run(["checkout", "-q", "-b", "feature", "HEAD~1"]);
commit("mine.ts", "export const mine = 1;\n", "my work");
run(["merge", "-q", "--no-edit", "main"]);
// origin/feature does not exist yet, so the old code diffed against the
// remote's main — dragging in every catch-up commit.
const scanned = addedOnly(addedLinesFromTwoDot(dir, "HEAD~2"));
expect(scanned).toContain(FAKE_DB_URL);
});
});
describe("narrowing the range does not narrow coverage", () => {
test("a secret in a new commit is still scanned", () => {
setUpRemoteWithForeignFixture();
run(["checkout", "-q", "-b", "feature", "main"]);
commit("leak.ts", `const k = "${FAKE_AWS_SECRETX}";\n`, "oops");
expect(addedOnly(addedLinesFromNewCommits(dir))).toContain(FAKE_AWS_SECRETX);
});
test("a secret introduced while RESOLVING a merge is still scanned", () => {
// A combined diff shows only content present in no parent — exactly the
// conflict resolution — so this must not slip through.
//
// Note for anyone hardening this later: removing `--cc` from the
// implementation does NOT fail this test, because `git show` already
// defaults to a combined diff for merge commits. The explicit flag is
// self-documenting, not load-bearing, and no test can pin it. What this
// test does pin is the coverage itself.
setUpRemoteWithForeignFixture();
run(["checkout", "-q", "-b", "feature", "HEAD~1"]);
commit("conflict.txt", "mine\n", "mine");
run(["checkout", "-q", "main"]);
commit("conflict.txt", "theirs\n", "theirs");
run(["push", "-q", "origin", "main"]);
run(["fetch", "-q", "origin"]);
run(["checkout", "-q", "feature"]);
spawnSync("git", ["merge", "--no-edit", "main"], { cwd: dir, encoding: "utf8" }); // conflicts
writeFileSync(join(dir, "conflict.txt"), `resolved ${FAKE_AWS_RESOLV}\n`);
run(["add", "conflict.txt"]);
run(["commit", "-q", "--no-edit"]);
expect(addedOnly(addedLinesFromNewCommits(dir))).toContain(FAKE_AWS_RESOLV);
});
test("everything is scanned when no remote exists at all", () => {
commit("leak.ts", `const k = "${FAKE_AWS_NOREMOT}";\n`, "no remote");
expect(addedOnly(addedLinesFromNewCommits(dir))).toContain(FAKE_AWS_NOREMOT);
});
});
// ── S1: the exclusion is scoped to the PUSH TARGET's remote ─────────────────
//
// A bare `--remotes` excludes commits reachable from ANY remote-tracking ref,
// so a secret that had only ever reached a private/local-path remote was never
// scanned when pushed to a PUBLIC remote. Git hands pre-push the push remote's
// name as $1; the hook now scopes the exclusion to `--remotes=<name>/*`.
// These run END-TO-END through the hook binary with the real argv + stdin
// protocol, because the behavior under test is the argv threading itself.
describe("S1: exclusion scoped to the push-target remote", () => {
const PREPUSH = join(import.meta.dir, "..", "bin", "gstack-redact-prepush");
const FAKE_AWS_OTHERREM = ["AKIA", "IOSFODNN7OTHERRM"].join("");
function runHook(stdinLines: string, argv: string[]): { code: number; stderr: string } {
const r = spawnSync("bun", [PREPUSH, ...argv], {
cwd: dir,
input: Buffer.from(stdinLines),
encoding: "utf8",
env: { ...process.env },
});
return { code: r.status ?? 0, stderr: r.stderr ?? "" };
}
/**
* Build the S1 shape. Returns the feature branch's last-pushed origin tip
* (what git hands the hook as remoteSha):
* 1. main + feature pushed to origin (T0 = feature's origin tip)
* 2. a HIGH-shaped secret commit reaches a SECOND remote only
* (pushed there, fetched back other/leaky tracking ref)
* 3. feature merges the secret commit the next push to origin is
* the first time this content heads anywhere public
*/
function buildSecretOnSecondRemote(): { originTip: string } {
const origin = mkdtempSync(join(tmpdir(), "gstack-prepush-origin-"));
run(["init", "-q", "--bare", "-b", "main"], origin);
run(["remote", "add", "origin", origin]);
run(["push", "-q", "origin", "main"]);
run(["checkout", "-q", "-b", "feature"]);
commit("mine.ts", "export const mine = 1;\n", "my work");
run(["push", "-q", "-u", "origin", "feature"]);
const originTip = run(["rev-parse", "HEAD"]).trim();
const other = mkdtempSync(join(tmpdir(), "gstack-prepush-other-"));
run(["init", "-q", "--bare", "-b", "main"], other);
run(["remote", "add", "other", other]);
run(["checkout", "-q", "-b", "leaky"]);
commit("leak.ts", `const k = "${FAKE_AWS_OTHERREM}";\n`, "secret to private remote only");
run(["push", "-q", "other", "leaky"]);
run(["fetch", "-q", "other"]);
run(["checkout", "-q", "feature"]);
// --no-ff: a fast-forward would make the secret commit the branch TIP,
// where the remoteSha two-dot fallback catches it regardless of the
// --remotes exclusion. The hole shape needs a real merge commit, so the
// narrowed path (per-commit --cc diffs) is what decides coverage.
run(["merge", "-q", "--no-ff", "--no-edit", "leaky"]);
return { originTip };
}
test("a commit known only to a SECOND remote IS scanned when pushing to origin", () => {
const { originTip } = buildSecretOnSecondRemote();
const head = run(["rev-parse", "HEAD"]).trim();
const { code, stderr } = runHook(
`refs/heads/feature ${head} refs/heads/feature ${originTip}\n`,
["origin", "file:///ignored"],
);
expect(code).toBe(1);
expect(stderr).toContain("BLOCKED");
expect(stderr).toContain("aws.access_key");
});
test("origin-published commits still are NOT re-scanned (catch-up merge, #2592 kept)", () => {
setUpRemoteWithForeignFixture();
run(["checkout", "-q", "-b", "feature", "HEAD~1"]);
commit("mine.ts", "export const mine = 1;\n", "my work");
run(["push", "-q", "-u", "origin", "feature"]);
const originTip = run(["rev-parse", "HEAD"]).trim();
run(["merge", "-q", "--no-edit", "main"]); // catch-up merge brings the foreign fixture
const head = run(["rev-parse", "HEAD"]).trim();
const { code, stderr } = runHook(
`refs/heads/feature ${head} refs/heads/feature ${originTip}\n`,
["origin", "file:///ignored"],
);
expect(stderr).not.toContain("BLOCKED");
expect(code).toBe(0);
});
test("no argv (stdin/CLI invocation) falls back to the historical all-remotes exclusion", () => {
// Documented contract, not a gap being celebrated: without the remote
// name there is nothing to scope to, and the fallback scans exactly what
// the hook always scanned. The installed hook wrapper forwards "$@", so
// real pushes always carry the name.
const { originTip } = buildSecretOnSecondRemote();
const head = run(["rev-parse", "HEAD"]).trim();
const { code, stderr } = runHook(
`refs/heads/feature ${head} refs/heads/feature ${originTip}\n`,
[],
);
expect(stderr).not.toContain("BLOCKED");
expect(code).toBe(0);
});
test("an unconfigured name (URL push) also falls back rather than erroring", () => {
const { originTip } = buildSecretOnSecondRemote();
const head = run(["rev-parse", "HEAD"]).trim();
const url = "file:///not-a-configured-remote";
const { code, stderr } = runHook(
`refs/heads/feature ${head} refs/heads/feature ${originTip}\n`,
[url, url],
);
expect(stderr).not.toContain("could not");
expect(code).toBe(0);
});
});
+75 -3
View File
@@ -215,9 +215,15 @@ describe('gstack-relink (#578)', () => {
const aliasSkill = path.join(aliasDir, 'SKILL.md');
expect(fs.lstatSync(aliasDir).isDirectory()).toBe(true);
expect(fs.lstatSync(aliasDir).isSymbolicLink()).toBe(false);
expect(fs.lstatSync(aliasSkill).isSymbolicLink()).toBe(true);
expect(fs.readlinkSync(aliasSkill)).toBe(path.join(installDir, 'SKILL.md'));
expect(fs.readFileSync(aliasSkill, 'utf-8')).toContain('name: gstack');
// #2511: the alias is a rewritten COPY, never a symlink. A symlinked
// alias re-serves the canonical `name: gstack`; Claude Code refuses
// duplicate skill names and drops the entire personal-skills set.
expect(fs.lstatSync(aliasSkill).isSymbolicLink()).toBe(false);
const aliasContent = fs.readFileSync(aliasSkill, 'utf-8');
expect(aliasContent).toContain('name: _gstack-command');
expect(aliasContent).not.toContain('name: gstack\n');
// The rewrite happened on the COPY: the canonical source keeps its name.
expect(fs.readFileSync(path.join(installDir, 'SKILL.md'), 'utf-8')).toContain('name: gstack');
run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix true`, {
GSTACK_INSTALL_DIR: installDir,
@@ -226,6 +232,72 @@ describe('gstack-relink (#578)', () => {
expect(fs.existsSync(aliasSkill)).toBe(true);
});
// #2201: connect-chrome ships as a dir SYMLINK to open-gstack-browser. The
// discovery loop used to link it under its own basename while its SKILL.md
// carried `name: open-gstack-browser` — a duplicate name that silently
// shadows the real skill (readdir-order roulette). Symlinked source dirs
// must be skipped; setup owns the rewritten-copy alias.
test('symlinked skill dirs are skipped, so no duplicate frontmatter names (#2201)', () => {
setupMockInstall(['open-gstack-browser', 'qa']);
fs.symlinkSync(
path.join(installDir, 'open-gstack-browser'),
path.join(installDir, 'connect-chrome'),
);
run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix false`, {
GSTACK_INSTALL_DIR: installDir,
GSTACK_SKILLS_DIR: skillsDir,
});
run(`${path.join(installDir, 'bin', 'gstack-relink')}`, {
GSTACK_INSTALL_DIR: installDir,
GSTACK_SKILLS_DIR: skillsDir,
});
expect(fs.existsSync(path.join(skillsDir, 'open-gstack-browser'))).toBe(true);
expect(fs.existsSync(path.join(skillsDir, 'connect-chrome'))).toBe(false);
// No two installed SKILL.md files may share a frontmatter name.
const names: string[] = [];
for (const entry of fs.readdirSync(skillsDir)) {
const skillMd = path.join(skillsDir, entry, 'SKILL.md');
if (!fs.existsSync(skillMd)) continue;
const m = fs.readFileSync(skillMd, 'utf-8').match(/^name:\s*(\S+)/m);
if (m) names.push(m[1]);
}
expect(new Set(names).size).toBe(names.length);
});
// #2569: rendered :user variants live in ${GSTACK_HOME}/render/claude.
// relink must serve the render when present — otherwise any config change
// silently flips every skill back to the canonical (blockless) source.
test('prefers a rendered SKILL.md from GSTACK_HOME/render/claude (#2569)', () => {
setupMockInstall(['qa', 'ship']);
const renderDir = path.join(tmpDir, 'render', 'claude', 'qa');
fs.mkdirSync(renderDir, { recursive: true });
fs.writeFileSync(
path.join(renderDir, 'SKILL.md'),
'---\nname: qa\ndescription: test\n---\nrendered brain-aware qa',
);
run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix false`, {
GSTACK_INSTALL_DIR: installDir,
GSTACK_SKILLS_DIR: skillsDir,
GSTACK_HOME: tmpDir,
});
run(`${path.join(installDir, 'bin', 'gstack-relink')}`, {
GSTACK_INSTALL_DIR: installDir,
GSTACK_SKILLS_DIR: skillsDir,
GSTACK_HOME: tmpDir,
});
const qaLink = path.join(skillsDir, 'qa', 'SKILL.md');
expect(fs.readlinkSync(qaLink)).toBe(path.join(renderDir, 'SKILL.md'));
expect(fs.readFileSync(qaLink, 'utf-8')).toContain('rendered brain-aware qa');
// ship has no render — canonical source link.
expect(fs.readlinkSync(path.join(skillsDir, 'ship', 'SKILL.md'))).toBe(
path.join(installDir, 'ship', 'SKILL.md'),
);
});
// FIRST INSTALL: --no-prefix must create ONLY flat names, zero gstack-* pollution
test('first install --no-prefix: only flat names exist, zero gstack-* entries', () => {
setupMockInstall(['qa', 'ship', 'review', 'plan-ceo-review', 'gstack-upgrade']);
+116
View File
@@ -0,0 +1,116 @@
/**
* Routing probe + team-init install resolution gate-tier tests (#2500).
*
* 1. The preamble's HAS_ROUTING probe must check AGENTS.md as well as
* CLAUDE.md. Non-Claude hosts (Codex, Cursor, generic harnesses) route
* skills via AGENTS.md the cross-harness convention file. Before this
* fix, a repo with AGENTS.md routing but no CLAUDE.md reported
* HAS_ROUTING: no and got nagged to create CLAUDE.md.
*
* 2. gstack-team-init's required-mode enforcement (the CLAUDE.md
* verification snippet and the generated check-gstack.sh hook) must
* resolve the install root across GSTACK_ROOT + every host's global
* install location, never hardcode ~/.claude/skills/gstack. The drift
* test pins the probe list against the hosts registry so a new host
* can't silently fall out of team-mode enforcement.
*
* Re-derived from community PR #2500 by @gamerey43.
*/
import { describe, test, expect } from 'bun:test';
import { execSync } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { HOST_PATHS } from '../scripts/resolvers/types';
import type { TemplateContext } from '../scripts/resolvers/types';
import { generatePreambleBash } from '../scripts/resolvers/preamble/generate-preamble-bash';
import { ALL_HOST_CONFIGS } from '../hosts/index';
const ROOT = path.join(import.meta.dir, '..');
function makeCtx(host: 'claude' | 'codex'): TemplateContext {
return {
skillName: 'test-skill',
tmplPath: 'test.tmpl',
host,
paths: HOST_PATHS[host],
preambleTier: 2,
};
}
/** Extract the routing-probe block from the rendered preamble bash. */
function extractRoutingProbe(rendered: string): string {
const start = rendered.indexOf('_HAS_ROUTING="no"');
expect(start).toBeGreaterThan(-1);
const end = rendered.indexOf('done', start);
expect(end).toBeGreaterThan(start);
return rendered.slice(start, end + 'done'.length);
}
describe('routing probe checks AGENTS.md too (#2500)', () => {
for (const host of ['claude', 'codex'] as const) {
test(`rendered preamble probes CLAUDE.md AND AGENTS.md (${host})`, () => {
const rendered = generatePreambleBash(makeCtx(host));
const probe = extractRoutingProbe(rendered);
expect(probe).toContain('CLAUDE.md');
expect(probe).toContain('AGENTS.md');
});
}
test('live probe block: AGENTS.md-only repo reports HAS_ROUTING=yes', () => {
const rendered = generatePreambleBash(makeCtx('claude'));
const probe = extractRoutingProbe(rendered);
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'routing-probe-'));
try {
fs.writeFileSync(
path.join(dir, 'AGENTS.md'),
'## Skill routing\n\n- Bugs → /investigate\n',
);
const out = execSync(
`bash -c '${probe.replace(/'/g, `'\\''`)}\necho "HAS_ROUTING: $_HAS_ROUTING"'`,
{ cwd: dir, encoding: 'utf-8' },
);
expect(out).toContain('HAS_ROUTING: yes');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('live probe block: repo with neither file reports HAS_ROUTING=no', () => {
const rendered = generatePreambleBash(makeCtx('claude'));
const probe = extractRoutingProbe(rendered);
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'routing-probe-'));
try {
const out = execSync(
`bash -c '${probe.replace(/'/g, `'\\''`)}\necho "HAS_ROUTING: $_HAS_ROUTING"'`,
{ cwd: dir, encoding: 'utf-8' },
);
expect(out).toContain('HAS_ROUTING: no');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
});
describe('team-init resolves GSTACK_ROOT across every host (#2500)', () => {
const teamInit = fs.readFileSync(path.join(ROOT, 'bin', 'gstack-team-init'), 'utf-8');
test('probe list covers GSTACK_ROOT env + every registered host globalRoot + migrated repo', () => {
expect(teamInit).toContain('"${GSTACK_ROOT:-}"');
for (const config of ALL_HOST_CONFIGS) {
expect(teamInit).toContain(`$HOME/${config.globalRoot}`);
}
expect(teamInit).toContain('$HOME/.gstack/repos/gstack');
});
test('enforcement no longer hardcodes the Claude path as the only gate', () => {
expect(teamInit).not.toContain('test -d ~/.claude/skills/gstack/bin');
expect(teamInit).not.toContain('if [ ! -d "$HOME/.claude/skills/gstack/bin" ]');
});
test('generated hook blocks only when NO install root resolves', () => {
// The hook's block branch must gate on the resolved root being empty,
// not on any single hardcoded directory.
expect(teamInit).toContain('if [ -z "$_GSTACK_ROOT" ]; then');
});
});
+164
View File
@@ -0,0 +1,164 @@
/**
* Alias name uniqueness (#2511 / #2201).
*
* setup installs two back-compat alias dirs `_gstack-command` (root router)
* and `connect-chrome` ( open-gstack-browser). Both used to symlink the
* canonical SKILL.md verbatim, so the alias carried the canonical frontmatter
* `name:`. Claude Code keys skills on that name and requires global
* uniqueness: the `connect-chrome` duplicate silently shadowed
* /open-gstack-browser (readdir-order roulette), and the `_gstack-command`
* duplicate could drop the ENTIRE personal-skills set.
*
* The fix is copy-then-rewrite: sed reads the SOURCE and writes a fresh copy
* with `name:` set to the alias dir's own name. Eng review E2 pinned the
* hazard this suite guards hardest: on Unix the old install path was a
* SYMLINK to the repo source, so an in-place sed through it would have
* corrupted the generated SKILL.md the source files must stay byte-intact.
*/
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');
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);
}
const installDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-alias-install-'));
const sourceRootSkill = fs.readFileSync(path.join(ROOT, 'SKILL.md'), 'utf-8');
const sourceOgbSkill = fs.readFileSync(
path.join(ROOT, 'open-gstack-browser', 'SKILL.md'),
'utf-8',
);
beforeAll(() => {
const installOnce = [
`link_claude_skill_dirs "${ROOT}" "${installDir}"`,
`link_claude_root_skill_alias "${ROOT}" "${installDir}"`,
// The connect-chrome back-compat alias, exactly as the install section does it.
`_install_alias_skill_md "${ROOT}/open-gstack-browser/SKILL.md" "${installDir}/connect-chrome" "connect-chrome"`,
].join('\n');
const script = [
'set -e',
'IS_WINDOWS=0',
'SKILL_PREFIX=0',
'QUIET=1',
'_WINDOWS_COPY_NOTE_PRINTED=1',
extractFn('_link_or_copy'),
extractFn('_print_windows_copy_note_once'),
extractFn('_link_skill_runtime_assets'),
extractFn('link_claude_skill_dirs'),
extractFn('_install_alias_skill_md'),
extractFn('link_claude_root_skill_alias'),
// Run TWICE: the second pass proves re-runs refresh instead of corrupting
// (the historical failure mode was sed'ing through a symlink on re-run).
installOnce,
installOnce,
].join('\n');
const result = spawnSync('bash', ['-c', script], { encoding: 'utf-8', timeout: 60_000 });
if (result.status !== 0) {
throw new Error(`alias install failed: ${result.stderr}\n${result.stdout}`);
}
}, 30_000);
afterAll(() => {
fs.rmSync(installDir, { recursive: true, force: true });
});
function frontmatterName(skillMdPath: string): string | null {
const m = fs.readFileSync(skillMdPath, 'utf-8').match(/^name:\s*(\S+)/m);
return m ? m[1] : null;
}
describe('alias installs are rewritten copies (#2511, #2201)', () => {
test('_gstack-command alias is NOT a symlink and carries its own name', () => {
const aliasDir = path.join(installDir, '_gstack-command');
const aliasSkill = path.join(aliasDir, 'SKILL.md');
expect(fs.lstatSync(aliasDir).isSymbolicLink()).toBe(false);
expect(fs.lstatSync(aliasSkill).isSymbolicLink()).toBe(false);
expect(frontmatterName(aliasSkill)).toBe('_gstack-command');
});
test('connect-chrome alias is NOT a symlink and carries its own name', () => {
const aliasDir = path.join(installDir, 'connect-chrome');
const aliasSkill = path.join(aliasDir, 'SKILL.md');
expect(fs.lstatSync(aliasDir).isSymbolicLink()).toBe(false);
expect(fs.lstatSync(aliasSkill).isSymbolicLink()).toBe(false);
expect(frontmatterName(aliasSkill)).toBe('connect-chrome');
});
test('alias body is the canonical content — only the name: line differs', () => {
const alias = fs.readFileSync(
path.join(installDir, '_gstack-command', 'SKILL.md'),
'utf-8',
);
expect(alias.replace(/^name:.*$/m, 'name: gstack')).toBe(sourceRootSkill);
const ogbAlias = fs.readFileSync(
path.join(installDir, 'connect-chrome', 'SKILL.md'),
'utf-8',
);
expect(ogbAlias.replace(/^name:.*$/m, 'name: open-gstack-browser')).toBe(sourceOgbSkill);
});
test('the SOURCE files are byte-intact (E2: sed never wrote through a symlink)', () => {
expect(fs.readFileSync(path.join(ROOT, 'SKILL.md'), 'utf-8')).toBe(sourceRootSkill);
expect(
fs.readFileSync(path.join(ROOT, 'open-gstack-browser', 'SKILL.md'), 'utf-8'),
).toBe(sourceOgbSkill);
expect(frontmatterName(path.join(ROOT, 'SKILL.md'))).toBe('gstack');
expect(frontmatterName(path.join(ROOT, 'open-gstack-browser', 'SKILL.md'))).toBe(
'open-gstack-browser',
);
});
test('every installed skill name is globally unique', () => {
const names: string[] = [];
for (const entry of fs.readdirSync(installDir)) {
const skillMd = path.join(installDir, entry, 'SKILL.md');
if (!fs.existsSync(skillMd)) continue;
const name = frontmatterName(skillMd);
if (name) names.push(name);
}
expect(names.length).toBeGreaterThan(10);
const dupes = names.filter((n, i) => names.indexOf(n) !== i);
expect(dupes).toEqual([]);
});
test('a legacy symlinked alias is replaced, not written through', () => {
// Simulate a pre-fix install: alias SKILL.md is a symlink to the source.
const legacyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-alias-legacy-'));
try {
const aliasDir = path.join(legacyDir, '_gstack-command');
fs.mkdirSync(aliasDir);
fs.symlinkSync(path.join(ROOT, 'SKILL.md'), path.join(aliasDir, 'SKILL.md'));
const script = [
'set -e',
'IS_WINDOWS=0',
extractFn('_link_or_copy'),
extractFn('_install_alias_skill_md'),
extractFn('link_claude_root_skill_alias'),
`link_claude_root_skill_alias "${ROOT}" "${legacyDir}"`,
].join('\n');
const result = spawnSync('bash', ['-c', script], { encoding: 'utf-8', timeout: 30_000 });
expect(result.status).toBe(0);
const aliasSkill = path.join(aliasDir, 'SKILL.md');
expect(fs.lstatSync(aliasSkill).isSymbolicLink()).toBe(false);
expect(frontmatterName(aliasSkill)).toBe('_gstack-command');
// The source the legacy symlink pointed at is untouched.
expect(fs.readFileSync(path.join(ROOT, 'SKILL.md'), 'utf-8')).toBe(sourceRootSkill);
} finally {
fs.rmSync(legacyDir, { recursive: true, force: true });
}
});
});
+8 -5
View File
@@ -45,16 +45,19 @@ describe('setup: gen:skill-docs:user exit-code propagation (pipe-masking fix)',
expect(r.stdout).not.toContain('LOG: warning');
});
test('setup: the live gbrain regen block has no pipe before the || guard', () => {
test('setup: the live gbrain render block has no pipe masking its exit code', () => {
// Slice the exact block from setup and confirm the fix is in place
// without resorting to a fragile line-number check.
const start = SETUP_SRC.indexOf('gbrain detected — regenerating');
// without resorting to a fragile line-number check. (#2569 renamed the
// block from "regenerating" to "rendering ... into $_GSTACK_RENDER_DIR" —
// the exit-code-propagation invariant is unchanged.)
const start = SETUP_SRC.indexOf('gbrain detected — rendering');
expect(start).toBeGreaterThan(-1);
const end = SETUP_SRC.indexOf('|| log', start);
const end = SETUP_SRC.indexOf('warning: gen:skill-docs:user failed', start);
expect(end).toBeGreaterThan(start);
const block = SETUP_SRC.slice(start, end);
expect(block).toContain('bun_cmd run gen:skill-docs:user --host claude');
// The bug shape: `... | tail -N` between the call and the `|| log` guard.
// The bug shape: `... | tail -N` between the call and the failure guard
// a pipe would replace the render's exit code with tail's.
expect(block).not.toMatch(/gen:skill-docs:user[^\n]*\|\s*tail/);
});
});
+225
View File
@@ -0,0 +1,225 @@
/**
* Claude installer runtime-asset coverage (#2317 / #2454).
*
* `link_claude_skill_dirs` historically installed only SKILL.md (+ sections/)
* per skill, so every skill that reads a sibling runtime file at
* `.claude/skills/<name>/<file>` review's checklist.md + specialists/, qa's
* templates/ + references/, gstack-upgrade's migrations/, careful/freeze's
* bin/ was broken on a fresh Claude install. This suite runs the REAL
* installer functions (extracted from `setup`) against the live repo into a
* temp skills dir and asserts the install is complete.
*
* Two-class referenced-paths assertion (eng review ENG-OV7):
* - Class 1 (alias-relative): a `.claude/skills/<name>/<relpath>` reference
* in an INSTALLED SKILL.md must resolve under the install dir. These are
* runtime reads against the flattened alias a miss is a broken skill.
* - Class 2 (repo-anchored): a `~/.claude/skills/gstack/<relpath>`
* reference must exist in the source tree, EXCEPT built artifacts
* (browse/dist, design/dist, make-pdf/dist, the compiled
* bin/gstack-global-discover) the free suite never builds binaries, so
* a naive "every path exists" either false-fails on dist or gets watered
* down to uselessness.
*/
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');
/** Built-at-setup artifacts: allowed to be absent from a fresh clone. */
const BUILT_ARTIFACT_ALLOWLIST = [
'browse/dist/',
'design/dist/',
'make-pdf/dist/',
'bin/gstack-global-discover', // compiled from bin/gstack-global-discover.ts at build time
];
/**
* Repo-anchored references that are KNOWN BROKEN on the current tree.
* Each entry must name the fix that removes it. An empty list is the goal
* do not add entries without an issue + a scheduled fix.
*/
const KNOWN_BROKEN_CLASS2: Record<string, string> = {
// (empty — #2250's bare bin names were the last entries; keep it that way)
};
/** Extract a named shell function body (through its closing brace) from setup. */
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);
}
const installDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-claude-install-'));
beforeAll(() => {
const script = [
'set -e',
'IS_WINDOWS=0',
'SKILL_PREFIX=0',
'QUIET=1',
'_WINDOWS_COPY_NOTE_PRINTED=1',
extractFn('_link_or_copy'),
extractFn('_print_windows_copy_note_once'),
extractFn('_link_skill_runtime_assets'),
extractFn('link_claude_skill_dirs'),
`link_claude_skill_dirs "${ROOT}" "${installDir}"`,
].join('\n');
const result = spawnSync('bash', ['-c', script], { encoding: 'utf-8', timeout: 60_000 });
if (result.status !== 0) {
throw new Error(`installer functions failed: ${result.stderr}\n${result.stdout}`);
}
});
afterAll(() => {
// rmSync does not follow symlinks — the repo sources the links point at survive.
fs.rmSync(installDir, { recursive: true, force: true });
});
function installedSkillDirs(): string[] {
return fs
.readdirSync(installDir, { withFileTypes: true })
.filter((e) => e.isDirectory())
.map((e) => e.name)
.filter((name) => fs.existsSync(path.join(installDir, name, 'SKILL.md')));
}
describe('link_claude_skill_dirs installs every runtime asset (#2317, #2454)', () => {
test('review skill ships its full runtime asset set', () => {
const review = path.join(installDir, 'review');
for (const asset of [
'checklist.md',
'design-checklist.md',
'greptile-triage.md',
'TODOS-format.md',
'specialists',
]) {
expect(fs.existsSync(path.join(review, asset))).toBe(true);
}
// specialists/ resolves to real content, not an empty shell
const specialists = fs.readdirSync(path.join(review, 'specialists'));
expect(specialists.length).toBeGreaterThan(0);
expect(specialists).toContain('testing.md');
});
test('the #2454 affected-skills table is fully installed', () => {
const expected: Array<[string, string]> = [
['qa', 'references'],
['qa', 'templates'],
['plan-devex-review', 'dx-hall-of-fame.md'],
['gstack-upgrade', 'migrations'],
['careful', 'bin'],
['freeze', 'bin'],
];
for (const [skill, asset] of expected) {
expect(fs.existsSync(path.join(installDir, skill, asset))).toBe(true);
}
});
test('sections/ still installs for carved skills', () => {
expect(fs.existsSync(path.join(installDir, 'ship', 'sections'))).toBe(true);
expect(
fs.readdirSync(path.join(installDir, 'ship', 'sections')).length,
).toBeGreaterThan(0);
});
test('exclusion list holds: no node_modules, dist, test, or .tmpl installed', () => {
for (const skill of installedSkillDirs()) {
const entries = fs.readdirSync(path.join(installDir, skill));
expect(entries).not.toContain('node_modules');
expect(entries).not.toContain('dist');
expect(entries).not.toContain('test');
const tmpl = entries.filter((e) => e.endsWith('.tmpl'));
expect(tmpl).toEqual([]);
}
});
test('hidden files are not installed', () => {
for (const skill of installedSkillDirs()) {
const hidden = fs
.readdirSync(path.join(installDir, skill))
.filter((e) => e.startsWith('.'));
expect(hidden).toEqual([]);
}
});
});
// ---------------------------------------------------------------------------
// Two-class referenced-paths assertion (ENG-OV7)
// ---------------------------------------------------------------------------
interface Ref {
fromSkill: string;
skillName: string;
rel: string;
}
const REF_RE = /~?\.claude\/skills\/([A-Za-z0-9_-]+)\/([A-Za-z0-9_.\/-]+)/g;
/** Placeholder-ish captures (globs, template vars, <angle> examples) are prose, not paths. */
function isConcretePath(raw: string): boolean {
return !/[<>*$(){}|]/.test(raw) && !raw.includes('..');
}
function collectRefs(): Ref[] {
const refs: Ref[] = [];
for (const skill of installedSkillDirs()) {
const content = fs.readFileSync(path.join(installDir, skill, 'SKILL.md'), 'utf-8');
for (const m of content.matchAll(REF_RE)) {
const rel = m[2].replace(/[.,:;/]+$/, '');
if (!rel || !isConcretePath(rel)) continue;
refs.push({ fromSkill: skill, skillName: m[1], rel });
}
}
return refs;
}
describe('two-class referenced-paths (ENG-OV7)', () => {
test('class 1: alias-relative references resolve under the install dir', () => {
const missing: string[] = [];
for (const { fromSkill, skillName, rel } of collectRefs()) {
if (skillName === 'gstack') continue; // class 2
// Prefix-mode prose may reference gstack-<name>; the flat install dir
// is the unprefixed name.
const candidates = [skillName, skillName.replace(/^gstack-/, '')];
const found = candidates.some((c) => fs.existsSync(path.join(installDir, c, rel)));
if (!found) missing.push(`${fromSkill}/SKILL.md → .claude/skills/${skillName}/${rel}`);
}
expect(missing).toEqual([]);
});
test('class 2: repo-anchored references exist in the tree (modulo built artifacts)', () => {
const missing: string[] = [];
for (const { fromSkill, skillName, rel } of collectRefs()) {
if (skillName !== 'gstack') continue; // class 1
if (rel.startsWith('.')) continue; // runtime state markers (.feature-prompted-*, .git)
if (BUILT_ARTIFACT_ALLOWLIST.some((a) => rel === a || rel.startsWith(a))) continue;
if (KNOWN_BROKEN_CLASS2[rel]) continue;
if (!fs.existsSync(path.join(ROOT, rel))) {
missing.push(`${fromSkill}/SKILL.md → ~/.claude/skills/gstack/${rel}`);
}
}
expect(missing).toEqual([]);
});
test('the referenced-path scan actually sees the review checklist refs (self-check)', () => {
// Guard against the extraction regex silently rotting: the review skill is
// KNOWN to carry alias-relative refs; if the scanner stops seeing them the
// class-1 assertion is vacuous.
const class1 = collectRefs().filter((r) => r.skillName !== 'gstack');
expect(class1.length).toBeGreaterThan(0);
expect(class1.some((r) => r.skillName === 'review' && r.rel === 'checklist.md')).toBe(true);
});
test('KNOWN_BROKEN_CLASS2 entries are still actually broken (ratchet)', () => {
// When a fix lands, its entry MUST be removed so the class-2 assertion
// guards the path again.
for (const rel of Object.keys(KNOWN_BROKEN_CLASS2)) {
expect(fs.existsSync(path.join(ROOT, rel))).toBe(false);
}
});
});
@@ -0,0 +1,133 @@
// setup-gbrain bin invocation path lint.
//
// Pins the correct bun-run + .ts invocation form for gstack-memory-ingest
// and gstack-gbrain-sync wherever setup-gbrain's docs instruct the agent
// to run them. Regression coverage for #2393 / #2250: both scripts are
// .ts files with no package.json bin alias stripping the extension, so a
// bare name (no `bun run` prefix, no `.ts` suffix) fails with "No such
// file or directory" the moment an agent follows the doc literally.
//
// Why a structural test instead of a full Agent SDK E2E:
// - The failure is entirely in the prose an agent reads, not in
// runtime behavior a service test could exercise. A grep-based
// regression on the template/reference-doc text is fast (<200ms),
// free, and catches the same drift a full E2E would, without the
// token cost. Same rationale as test/setup-gbrain-path4-structure.test.ts.
// - The correct invocation form and the stale one differ only by
// `bun run ` + `.ts`, right next to each other in the same files —
// exactly the kind of drift a cheap structural check exists to catch,
// matching this repo's convention (e.g. test/memory-ingest-no-put_page.test.ts
// pinning fix #1346).
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 TMPL = path.join(ROOT, 'setup-gbrain', 'SKILL.md.tmpl');
const MEMORY_DOC = path.join(ROOT, 'setup-gbrain', 'memory.md');
const tmpl = fs.readFileSync(TMPL, 'utf-8');
const memoryDoc = fs.readFileSync(MEMORY_DOC, 'utf-8');
// A "bare invocation" is the tool name immediately followed by a flag/arg
// with no `.ts` in between — the exact stale shape #2393/#2250 reported.
// The negative lookahead means `gstack-memory-ingest.ts --probe` (correct)
// does NOT match, while `gstack-memory-ingest --probe` (stale) does.
// `(?:\s|\\\r?\n)+` also spans a backslash line-continuation between the
// name and its flag (e.g. `gstack-memory-ingest \` newline ` --probe`),
// a style this same template already uses for other commands (see the
// read_secret_to_env invocation a few hundred lines up) — a plain `\s+`
// would miss a stale invocation reintroduced in that form.
const bareMemoryIngest = /\bgstack-memory-ingest\b(?!\.ts)(?:\s|\\\r?\n)+--/;
const bareGbrainSync = /\bgstack-gbrain-sync\b(?!\.ts)(?:\s|\\\r?\n)+--/;
describe('setup-gbrain/SKILL.md.tmpl — bin invocation paths', () => {
test('no bare gstack-memory-ingest invocation remains', () => {
expect(tmpl).not.toMatch(bareMemoryIngest);
});
test('no bare gstack-gbrain-sync invocation remains', () => {
expect(tmpl).not.toMatch(bareGbrainSync);
});
test('the probe step uses bun run + .ts (R1)', () => {
expect(tmpl).toContain(
'bun run ~/.claude/skills/gstack/bin/gstack-memory-ingest.ts --probe'
);
});
test('the silent-bulk mention uses bun run + .ts (R2)', () => {
expect(tmpl).toContain(
'bun run ~/.claude/skills/gstack/bin/gstack-memory-ingest.ts --bulk --quiet'
);
});
test('the post-answer full-sync step uses bun run + .ts (R3)', () => {
expect(tmpl).toContain(
'bun run ~/.claude/skills/gstack/bin/gstack-gbrain-sync.ts --full --no-brain-sync'
);
});
test('the preamble-hook incremental-sync mention uses bun run + .ts (R4)', () => {
expect(tmpl).toContain(
'bun run ~/.claude/skills/gstack/bin/gstack-gbrain-sync.ts --incremental --quiet'
);
});
test('the neighboring gstack-config line in the post-answer block is untouched (bash script, no extension)', () => {
expect(tmpl).toContain(
'~/.claude/skills/gstack/bin/gstack-config set transcript_ingest_mode <choice>'
);
});
test('the prose-only mention naming the tool as a sentence subject is left unchanged (KTD4 — not a literal invocation)', () => {
expect(tmpl).toContain('gstack-memory-ingest now persists staged transcripts to');
});
});
describe('setup-gbrain/memory.md — bin invocation paths', () => {
test('no bare gstack-memory-ingest invocation remains', () => {
expect(memoryDoc).not.toMatch(bareMemoryIngest);
});
test('no bare gstack-gbrain-sync invocation remains', () => {
expect(memoryDoc).not.toMatch(bareGbrainSync);
});
test('the secret-scanning example uses bun run + .ts (R5)', () => {
expect(memoryDoc).toContain('bun run bin/gstack-memory-ingest.ts --bulk --scan-secrets');
expect(memoryDoc).toContain(
'GSTACK_MEMORY_INGEST_SCAN_SECRETS=1 bun run bin/gstack-memory-ingest.ts --bulk'
);
});
test('the troubleshooting full-pass mention uses bun run + .ts (R5)', () => {
expect(memoryDoc).toContain('Run `bun run bin/gstack-gbrain-sync.ts --full` to do a full pass.');
});
test('the troubleshooting incremental-reingest mention uses bun run + .ts (R5)', () => {
expect(memoryDoc).toContain(
're-run `bun run bin/gstack-gbrain-sync.ts --incremental` to re-ingest from'
);
});
test('the already-correct reference line at the top of the file is unchanged', () => {
expect(memoryDoc).toContain('bun run bin/gstack-memory-ingest.ts --probe` (which');
});
});
describe('bare-invocation regex — backslash line-continuation coverage', () => {
// This template writes multi-line commands with a trailing backslash
// continuation elsewhere (e.g. the read_secret_to_env invocation), so a
// stale invocation reintroduced in that same style must still be caught.
test('catches a bare invocation split across a backslash continuation', () => {
const staleContinuation = 'gstack-memory-ingest \\\n --probe';
expect(staleContinuation).toMatch(bareMemoryIngest);
});
test('does not flag a correct invocation split across a backslash continuation', () => {
const fixedContinuation = 'bun run bin/gstack-gbrain-sync.ts \\\n --incremental';
expect(fixedContinuation).not.toMatch(bareGbrainSync);
});
});
+10 -5
View File
@@ -24,12 +24,17 @@ function fnBody(src: string, name: string): string {
}
describe('setup links sections/ for cherry-pick install targets', () => {
test('link_claude_skill_dirs links sections/ via _link_or_copy', () => {
test('link_claude_skill_dirs installs runtime assets (incl. sections/) via the shared helper', () => {
// #2317/#2454 generalized the sections/-only install into
// _link_skill_runtime_assets, which carries EVERY runtime asset a skill
// references (sections/, checklist.md, specialists/, ...). That helper
// routes through _link_or_copy internally (windows-safe), so the old
// per-directory _link_or_copy assertion moved there.
const body = fnBody(SETUP, 'link_claude_skill_dirs');
expect(body).toContain('sections');
// sections install must route through the windows-safe helper, not raw ln.
expect(body).toMatch(/_link_or_copy\s+"\$gstack_dir\/\$dir_name\/sections"\s+"\$target\/sections"/);
expect(body).toMatch(/if \[ -d "\$gstack_dir\/\$dir_name\/sections" \]/);
expect(body).toMatch(/_link_skill_runtime_assets\s+"\$gstack_dir\/\$dir_name"\s+"\$target"/);
const helper = fnBody(SETUP, '_link_skill_runtime_assets');
expect(helper).toContain('_link_or_copy');
expect(helper).not.toMatch(/\bln -s/);
});
test('kiro per-skill loop rewrites + copies sections/*', () => {
+356
View File
@@ -0,0 +1,356 @@
/**
* Windows re-run refresh (#2444).
*
* On Windows, _link_or_copy installs REAL directory copies (no Developer
* Mode symlinks). The skill-linking guards `[ -L "$target" ] || [ ! -e
* "$target" ]` in link_codex_skill_dirs / link_factory_skill_dirs /
* link_opencode_skill_dirs / create_agents_sidecar therefore skipped every
* re-run: `./setup --host codex` reported "gstack ready" but never refreshed
* an already-installed SKILL.md after `git pull`. The fix bypasses the guard
* when IS_WINDOWS=1 _link_or_copy rm -rf's the destination first, so the
* copy refreshes in place.
*
* The behavior fixture drives the REAL link_codex_skill_dirs /
* create_agents_sidecar functions (extracted from setup) against a fake
* install tree; the static block pins the bypass at all five guard sites so
* factory/opencode can't silently regress.
*/
import { describe, test, expect } 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');
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);
}
const WINDOWS_BYPASS = '[ "$IS_WINDOWS" -eq 1 ] || [ -L ';
describe('setup: Windows re-run refresh — static guard sites (#2444)', () => {
test('no install guard is missing the IS_WINDOWS bypass', () => {
// Every `[ -L ...] || [ ! -e ...]` refresh guard in setup must carry the
// bypass — a bare guard is a Windows re-run no-op waiting to happen.
const bareGuards = SETUP_SRC
.split('\n')
.filter((l) => /\[ -L "\$[A-Za-z_/${}.]+" \] \|\| \[ ! -e /.test(l) && !l.includes('IS_WINDOWS'));
expect(bareGuards).toEqual([]);
expect(SETUP_SRC.split(WINDOWS_BYPASS).length - 1).toBeGreaterThanOrEqual(5);
});
test.each([
'link_codex_skill_dirs',
'link_factory_skill_dirs',
'link_opencode_skill_dirs',
'link_cursor_skill_dirs',
'create_agents_sidecar',
'create_cursor_sidecar',
])('%s bypasses the symlink-or-missing guard on Windows', (fn) => {
expect(extractFn(fn)).toContain(WINDOWS_BYPASS);
});
// #2142 ownership census: the Windows bypass rm -rf's real dirs, so every
// skill-dir installer must gate the replacement on provable gstack
// ownership, and every sidecar/runtime-root installer must refuse a
// user-owned root. A bypass without its gate deletes user data.
test.each([
'link_codex_skill_dirs',
'link_factory_skill_dirs',
'link_opencode_skill_dirs',
'link_cursor_skill_dirs',
])('%s gates the Windows real-dir replacement on _owned_for_windows_refresh', (fn) => {
expect(extractFn(fn)).toContain('_owned_for_windows_refresh "$target"');
});
test.each([
'create_agents_sidecar',
'create_cursor_sidecar',
'create_cursor_runtime_root',
])('%s refuses a user-owned root via _sidecar_root_user_owned', (fn) => {
expect(extractFn(fn)).toContain('_sidecar_root_user_owned');
});
});
describe('setup: Windows refresh ownership gate — behavior fixture (#2142)', () => {
test("IS_WINDOWS=1: a user's own real dir on a gstack* name survives; a bannered install refreshes", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-owned-'));
try {
const fake = path.join(tmp, 'gstack');
const skills = path.join(tmp, 'skills');
const banner = '<!-- AUTO-GENERATED from SKILL.md.tmpl - DO NOT EDIT DIRECTLY -->\n';
// Generated tree ships two skills.
for (const name of ['gstack-demo', 'gstack-notes']) {
const d = path.join(fake, '.agents', 'skills', name);
fs.mkdirSync(d, { recursive: true });
fs.writeFileSync(path.join(d, 'SKILL.md'), `${banner}upstream-v2\n`);
}
fs.mkdirSync(skills, { recursive: true });
// gstack-demo: a prior gstack install (bannered) — must refresh.
fs.mkdirSync(path.join(skills, 'gstack-demo'), { recursive: true });
fs.writeFileSync(path.join(skills, 'gstack-demo', 'SKILL.md'), `${banner}installed-v1\n`);
// gstack-notes: the USER'S own hand-written skill — must survive.
fs.mkdirSync(path.join(skills, 'gstack-notes'), { recursive: true });
fs.writeFileSync(path.join(skills, 'gstack-notes', 'SKILL.md'), '# my own notes\n');
const r = runInstaller(
'1',
['_owned_for_windows_refresh', 'link_codex_skill_dirs'],
`link_codex_skill_dirs "${tmp}/gstack" "${skills}"`,
);
expect(r.status).toBe(0);
expect(fs.readFileSync(path.join(skills, 'gstack-demo', 'SKILL.md'), 'utf-8')).toContain('upstream-v2');
expect(fs.readFileSync(path.join(skills, 'gstack-notes', 'SKILL.md'), 'utf-8')).toBe('# my own notes\n');
expect(r.stderr).toContain('left in place');
expect(r.stderr).toContain('gstack-notes');
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test('IS_WINDOWS=1: create_agents_sidecar refuses a user-owned root and writes nothing into it', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-owned-sidecar-'));
try {
const fake = path.join(tmp, 'gstack');
fs.mkdirSync(path.join(fake, 'bin'), { recursive: true });
fs.writeFileSync(path.join(fake, 'bin', 'tool.sh'), 'v1\n');
// The user's own skill squats on .agents/skills/gstack.
const root = path.join(fake, '.agents', 'skills', 'gstack');
fs.mkdirSync(root, { recursive: true });
fs.writeFileSync(path.join(root, 'SKILL.md'), '# hand-written\n');
const vars = `SOURCE_GSTACK_DIR="${fake}"`;
const r = runInstaller(
'1',
['_sidecar_root_user_owned', 'create_agents_sidecar'],
`create_agents_sidecar "${fake}"`,
vars,
);
expect(r.status).toBe(0);
expect(r.stderr).toContain('left in place');
expect(fs.existsSync(path.join(root, 'bin'))).toBe(false);
expect(fs.readFileSync(path.join(root, 'SKILL.md'), 'utf-8')).toBe('# hand-written\n');
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
});
interface RunResult {
status: number | null;
stdout: string;
stderr: string;
}
/** Run the extracted installer functions against a fake tree. */
function runInstaller(
isWindows: '0' | '1',
fns: string[],
invocation: string,
extraVars = '',
): RunResult {
const script = [
'set -e',
`IS_WINDOWS=${isWindows}`,
extraVars,
extractFn('_link_or_copy'),
// Ownership gates (#2142) — dependencies of every installer under test.
extractFn('_owned_for_windows_refresh'),
extractFn('_sidecar_root_user_owned'),
...fns.map(extractFn),
invocation,
].join('\n');
const r = spawnSync('bash', ['-c', script], { encoding: 'utf-8', timeout: 15_000 });
return { status: r.status, stdout: r.stdout, stderr: r.stderr };
}
describe('setup: Windows re-run refresh — behavior fixture (#2444)', () => {
test('IS_WINDOWS=1: link_codex_skill_dirs refreshes an already-installed skill', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-rerun-'));
try {
const fake = path.join(tmp, 'gstack');
const skills = path.join(tmp, 'skills');
const demo = path.join(fake, '.agents', 'skills', 'gstack-demo');
fs.mkdirSync(demo, { recursive: true });
fs.mkdirSync(skills, { recursive: true });
// Generated SKILL.md files always carry the banner — the #2142
// ownership gate keys the Windows refresh on it.
const banner = '<!-- AUTO-GENERATED from SKILL.md.tmpl - DO NOT EDIT DIRECTLY -->\n';
fs.writeFileSync(path.join(demo, 'SKILL.md'), `${banner}v1-original\n`);
// First run: installs the copy.
let r = runInstaller('1', ['link_codex_skill_dirs'], `link_codex_skill_dirs "${fake}" "${skills}"`);
expect(r.status).toBe(0);
const installed = path.join(skills, 'gstack-demo', 'SKILL.md');
expect(fs.readFileSync(installed, 'utf-8')).toBe(`${banner}v1-original\n`);
expect(fs.lstatSync(path.join(skills, 'gstack-demo')).isSymbolicLink()).toBe(false);
// Upstream ships a change (the git pull).
fs.writeFileSync(path.join(demo, 'SKILL.md'), `${banner}v2-UPDATED\n`);
// Second run: pre-#2444 this was a silent no-op on Windows.
r = runInstaller('1', ['link_codex_skill_dirs'], `link_codex_skill_dirs "${fake}" "${skills}"`);
expect(r.status).toBe(0);
expect(fs.readFileSync(installed, 'utf-8')).toBe(`${banner}v2-UPDATED\n`);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test('IS_WINDOWS=1: create_agents_sidecar refreshes copied runtime assets', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-rerun-sidecar-'));
try {
const fake = path.join(tmp, 'gstack');
fs.mkdirSync(path.join(fake, 'bin'), { recursive: true });
fs.writeFileSync(path.join(fake, 'bin', 'tool.sh'), 'v1\n');
fs.writeFileSync(path.join(fake, 'ETHOS.md'), 'ethos-v1\n');
const vars = `SOURCE_GSTACK_DIR="${fake}"`;
let r = runInstaller('1', ['create_agents_sidecar'], `create_agents_sidecar "${fake}"`, vars);
expect(r.status).toBe(0);
const sidecarBin = path.join(fake, '.agents', 'skills', 'gstack', 'bin', 'tool.sh');
const sidecarEthos = path.join(fake, '.agents', 'skills', 'gstack', 'ETHOS.md');
expect(fs.readFileSync(sidecarBin, 'utf-8')).toBe('v1\n');
expect(fs.readFileSync(sidecarEthos, 'utf-8')).toBe('ethos-v1\n');
fs.writeFileSync(path.join(fake, 'bin', 'tool.sh'), 'v2\n');
fs.writeFileSync(path.join(fake, 'ETHOS.md'), 'ethos-v2\n');
r = runInstaller('1', ['create_agents_sidecar'], `create_agents_sidecar "${fake}"`, vars);
expect(r.status).toBe(0);
expect(fs.readFileSync(sidecarBin, 'utf-8')).toBe('v2\n');
expect(fs.readFileSync(sidecarEthos, 'utf-8')).toBe('ethos-v2\n');
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test('IS_WINDOWS=1: nested gitignored build output does NOT survive the runtime-asset copy (P5)', () => {
// The exclusion list in _link_skill_runtime_assets filters direct
// children only; cp -R swept NESTED node_modules/.build/dist too
// (concrete: ios-qa/scripts/gen-accessors-tool/.build, 252MB). The
// Windows branch prunes them post-copy; real asset files at every level
// survive.
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-rerun-prune-'));
try {
const src = path.join(tmp, 'skill-src');
const dst = path.join(tmp, 'skill-dst');
fs.mkdirSync(path.join(src, 'scripts', 'gen-tool', '.build'), { recursive: true });
fs.mkdirSync(path.join(src, 'scripts', 'gen-tool', 'node_modules', 'dep'), { recursive: true });
fs.mkdirSync(path.join(src, 'scripts', 'gen-tool', 'dist'), { recursive: true });
fs.mkdirSync(dst, { recursive: true });
fs.writeFileSync(path.join(src, 'scripts', 'runner.sh'), 'echo run\n');
fs.writeFileSync(path.join(src, 'scripts', 'gen-tool', 'main.swift'), 'source\n');
fs.writeFileSync(path.join(src, 'scripts', 'gen-tool', '.build', 'blob.bin'), '#'.repeat(4096));
fs.writeFileSync(path.join(src, 'scripts', 'gen-tool', 'node_modules', 'dep', 'index.js'), 'x\n');
fs.writeFileSync(path.join(src, 'scripts', 'gen-tool', 'dist', 'compiled'), 'bin\n');
const r = runInstaller(
'1',
['_link_skill_runtime_assets'],
`_link_skill_runtime_assets "${src}" "${dst}"`,
);
expect(r.status).toBe(0);
// Real assets at both levels survive…
expect(fs.readFileSync(path.join(dst, 'scripts', 'runner.sh'), 'utf-8')).toBe('echo run\n');
expect(fs.readFileSync(path.join(dst, 'scripts', 'gen-tool', 'main.swift'), 'utf-8')).toBe('source\n');
// …nested build output does not.
expect(fs.existsSync(path.join(dst, 'scripts', 'gen-tool', '.build'))).toBe(false);
expect(fs.existsSync(path.join(dst, 'scripts', 'gen-tool', 'node_modules'))).toBe(false);
expect(fs.existsSync(path.join(dst, 'scripts', 'gen-tool', 'dist'))).toBe(false);
// The source tree is untouched — the prune runs on the COPY only.
expect(fs.existsSync(path.join(src, 'scripts', 'gen-tool', '.build', 'blob.bin'))).toBe(true);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test('IS_WINDOWS=0: the Unix symlink path never prunes through into the source', () => {
// On Unix the asset is a SYMLINK into the working tree; pruning through
// it would delete real build output from the repo. The prune is gated on
// the Windows real-copy shape ([ -d ] && [ ! -L ]).
// Not runnable ON Windows: this sub-case models the UNIX shape, but Git
// Bash's `ln -snf` produces a real copy there (no Developer Mode on CI),
// so the symlink assertion is false by platform, not by regression. The
// Unix lanes (macOS dev boxes + Linux CI) own this case.
if (process.platform === 'win32') return;
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-rerun-prune-unix-'));
try {
const src = path.join(tmp, 'skill-src');
const dst = path.join(tmp, 'skill-dst');
fs.mkdirSync(path.join(src, 'scripts', 'gen-tool', '.build'), { recursive: true });
fs.mkdirSync(dst, { recursive: true });
fs.writeFileSync(path.join(src, 'scripts', 'gen-tool', '.build', 'blob.bin'), 'keep');
const r = runInstaller(
'0',
['_link_skill_runtime_assets'],
`_link_skill_runtime_assets "${src}" "${dst}"`,
);
expect(r.status).toBe(0);
expect(fs.lstatSync(path.join(dst, 'scripts')).isSymbolicLink()).toBe(true);
expect(fs.existsSync(path.join(src, 'scripts', 'gen-tool', '.build', 'blob.bin'))).toBe(true);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test('IS_WINDOWS=1: the gstack sidecar dir is still skipped by the skill loop', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-rerun-skip-'));
try {
const fake = path.join(tmp, 'gstack');
const skills = path.join(tmp, 'skills');
const sidecar = path.join(fake, '.agents', 'skills', 'gstack');
fs.mkdirSync(sidecar, { recursive: true });
fs.mkdirSync(skills, { recursive: true });
fs.writeFileSync(path.join(sidecar, 'SKILL.md'), 'sidecar\n');
const r = runInstaller('1', ['link_codex_skill_dirs'], `link_codex_skill_dirs "${fake}" "${skills}"`);
expect(r.status).toBe(0);
expect(fs.existsSync(path.join(skills, 'gstack'))).toBe(false);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
});
// On real Windows, `ln -snf` under Git Bash silently produces copies, so the
// Unix-mode symlink assertions are meaningless there — the same skip the
// _link_or_copy behavior matrix uses (test/setup-windows-fallback.test.ts).
describe.skipIf(process.platform === 'win32')(
'setup: Unix path unchanged by the #2444 bypass',
() => {
test('IS_WINDOWS=0: installs a symlink and re-runs still refresh through it', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-rerun-unix-'));
try {
const fake = path.join(tmp, 'gstack');
const skills = path.join(tmp, 'skills');
const demo = path.join(fake, '.agents', 'skills', 'gstack-demo');
fs.mkdirSync(demo, { recursive: true });
fs.mkdirSync(skills, { recursive: true });
fs.writeFileSync(path.join(demo, 'SKILL.md'), 'v1-original\n');
let r = runInstaller('0', ['link_codex_skill_dirs'], `link_codex_skill_dirs "${fake}" "${skills}"`);
expect(r.status).toBe(0);
const target = path.join(skills, 'gstack-demo');
expect(fs.lstatSync(target).isSymbolicLink()).toBe(true);
// A symlink serves updates without any re-run at all…
fs.writeFileSync(path.join(demo, 'SKILL.md'), 'v2-UPDATED\n');
expect(fs.readFileSync(path.join(target, 'SKILL.md'), 'utf-8')).toBe('v2-UPDATED\n');
// …and the re-run keeps it a symlink (guard still passes via -L).
r = runInstaller('0', ['link_codex_skill_dirs'], `link_codex_skill_dirs "${fake}" "${skills}"`);
expect(r.status).toBe(0);
expect(fs.lstatSync(target).isSymbolicLink()).toBe(true);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
},
);
+49
View File
@@ -0,0 +1,49 @@
/**
* /ship review fix loop stays in one invocation (#2391).
*
* The pre-landing review used to commit its fixes, STOP, and tell the user
* to run /ship again 5-10 manual invocations on a branch with a few
* auto-fixable findings, violating the skill's fully-automated contract.
* The rendered section must instruct a bounded in-invocation loop
* (re-test, re-review, max 3 fix cycles) and must never terminate an
* AUTO-FIX result with a rerun request.
*/
import { describe, test, expect } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';
const ROOT = path.join(import.meta.dir, '..');
const RENDERED_SITES = [
path.join(ROOT, 'ship', 'sections', 'review-army.md'),
path.join(ROOT, 'test', 'fixtures', 'golden', 'claude-ship-SKILL.md'),
path.join(ROOT, 'test', 'fixtures', 'golden', 'codex-ship-SKILL.md'),
path.join(ROOT, 'test', 'fixtures', 'golden', 'factory-ship-SKILL.md'),
];
describe('/ship review fix loop (#2391)', () => {
test('no rendered ship surface instructs a STOP-and-rerun after fixes', () => {
// The pre-fix instruction: "then **STOP** and tell the user to run
// `/ship` again". The fixed text mentions the phrase only inside a
// NEVER-do-this prohibition, so match the imperative STOP shape.
const rerunRequest = /\*\*STOP\*\*[^\n]*run `\/ship` again/;
for (const file of RENDERED_SITES) {
const content = fs.readFileSync(file, 'utf-8');
expect(rerunRequest.test(content)).toBe(false);
}
});
test('rendered section instructs the bounded in-invocation loop', () => {
const content = fs.readFileSync(RENDERED_SITES[0], 'utf-8');
expect(content).toContain('stay in this invocation and loop');
expect(content).toContain('3 fix cycles');
// The loop re-runs tests AND the review, and only a converged pass continues.
expect(content).toContain('re-run the test suite (Step 5)');
expect(content).toContain('re-run this review (Step 9 items 2-6)');
});
test('the non-convergence stop is a blocker report, not a rerun request', () => {
const content = fs.readFileSync(RENDERED_SITES[0], 'utf-8');
expect(content).toContain('report which findings keep reappearing');
});
});
+1 -1
View File
@@ -11,7 +11,7 @@ import * as fs from 'fs';
import * as path from 'path';
import { frontmatterName, skillCensus } from './helpers/skill-census';
const ROOT = path.resolve(new URL(import.meta.url).pathname, '..', '..');
const ROOT = path.resolve(import.meta.path, '..', '..');
const census = skillCensus(ROOT);
describe('skillCensus', () => {
+8 -3
View File
@@ -161,9 +161,14 @@ IMPORTANT:
- Focus on changes in the current branch vs main.
- The webhook.ts file was added on this branch it should be analyzed.`,
workingDirectory: csoDiffDir,
maxTurns: 25,
maxTurns: 40,
allowedTools: ['Bash', 'Read', 'Write', 'Edit', 'Grep', 'Glob', 'Agent'],
timeout: 240_000,
// 360s/40 turns: the v1.67 wave grew the audit session legitimately —
// transcript-verified, the agent finds the webhook vuln, spawns the
// verification subagent, and writes the report, then gets killed at
// ~215s in its CLOSING telemetry under the old 240s/25-turn budget.
// The full-audit sibling already runs at 300s.
timeout: 360_000,
});
logCost('cso', result);
@@ -176,7 +181,7 @@ IMPORTANT:
).toBe(true);
recordE2E(evalCollector, 'cso-diff-mode', 'e2e-cso', result);
}, 240_000);
}, 400_000);
});
describeIfSelected('CSO v2 — infra scope', ['cso-infra-scope'], () => {
+252
View File
@@ -0,0 +1,252 @@
/**
* Timeline Stop hook (#2553) fail-open contract (F5).
*
* The preamble writes event:"started" at every skill start; the completion
* write is end-of-workflow prose and unenforceable, so interrupted sessions
* leaked started > completed forever. The Stop hook closes dangling entries.
*
* Contract under test: ALWAYS exits 0 (corrupt timeline, missing timeline,
* garbage stdin), append-only, and the normal path appends event:"completed"
* with outcome "unknown" + source "stop-hook" for every un-closed "started".
*/
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';
const ROOT = path.resolve(import.meta.dir, '..');
const HOOK = path.join(ROOT, 'hosts', 'claude', 'hooks', 'timeline-stop-hook');
const SLUG = 'stop-hook-test-project';
let tmpHome: string;
let projectDir: string;
let timelinePath: string;
beforeEach(() => {
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-stop-hook-home-'));
projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-stop-hook-proj-'));
fs.mkdirSync(path.join(tmpHome, 'projects', SLUG), { recursive: true });
timelinePath = path.join(tmpHome, 'projects', SLUG, 'timeline.jsonl');
});
afterEach(() => {
fs.rmSync(tmpHome, { recursive: true, force: true });
fs.rmSync(projectDir, { recursive: true, force: true });
});
function runHook(stdin: string): { exitCode: number; stdout: string; stderr: string } {
const r = spawnSync('bash', [HOOK], {
input: stdin,
encoding: 'utf-8',
env: {
...process.env,
GSTACK_HOME: tmpHome,
GSTACK_PROJECT_SLUG: SLUG, // deterministic slug, no git required
},
timeout: 15_000,
});
return { exitCode: r.status ?? 1, stdout: r.stdout, stderr: r.stderr };
}
function stopPayload(): string {
return JSON.stringify({
session_id: 'sess-abc',
hook_event_name: 'Stop',
cwd: projectDir,
});
}
function timelineEntries(): any[] {
if (!fs.existsSync(timelinePath)) return [];
return fs
.readFileSync(timelinePath, 'utf-8')
.split('\n')
.filter((l) => l.trim())
.map((l) => {
try {
return JSON.parse(l);
} catch {
return { __corrupt: l };
}
});
}
describe('timeline-stop-hook (#2553, F5 fail-open)', () => {
test('normal path: closes dangling started entries, leaves closed pairs alone', () => {
fs.writeFileSync(
timelinePath,
[
JSON.stringify({ skill: 'review', event: 'started', branch: 'main', session: '11-1' }),
JSON.stringify({ skill: 'ship', event: 'started', session: '22-2' }),
JSON.stringify({ skill: 'ship', event: 'completed', session: '22-2', outcome: 'success' }),
].join('\n') + '\n',
);
const r = runHook(stopPayload());
expect(r.exitCode).toBe(0);
const entries = timelineEntries();
// Append-only: the three originals survive verbatim in order.
expect(entries[0]).toMatchObject({ skill: 'review', event: 'started' });
expect(entries[2]).toMatchObject({ skill: 'ship', event: 'completed', outcome: 'success' });
const repairs = entries.filter((e) => e.source === 'stop-hook');
expect(repairs).toHaveLength(1);
expect(repairs[0]).toMatchObject({
skill: 'review',
event: 'completed',
outcome: 'unknown',
branch: 'main',
session: '11-1',
});
expect(typeof repairs[0].ts).toBe('string');
});
test('idempotent: a second Stop appends nothing new', () => {
fs.writeFileSync(
timelinePath,
JSON.stringify({ skill: 'qa', event: 'started', session: '33-3' }) + '\n',
);
expect(runHook(stopPayload()).exitCode).toBe(0);
const afterFirst = timelineEntries().length;
expect(runHook(stopPayload()).exitCode).toBe(0);
expect(timelineEntries().length).toBe(afterFirst);
});
test('count semantics: two runs under one key, one completed — the dangler is still repaired', () => {
// Legacy entries carry no session field, so both runs share the same
// skill+session key (same-second "$$-epoch" ids collide the same way).
// With set semantics the first run's completion masked the second run's
// dangler forever; counting closes the difference.
fs.writeFileSync(
timelinePath,
[
JSON.stringify({ skill: 'review', event: 'started' }),
JSON.stringify({ skill: 'review', event: 'completed', outcome: 'success' }),
JSON.stringify({ skill: 'review', event: 'started' }),
].join('\n') + '\n',
);
expect(runHook(stopPayload()).exitCode).toBe(0);
const repairs = timelineEntries().filter((e) => e.source === 'stop-hook');
expect(repairs).toHaveLength(1);
expect(repairs[0]).toMatchObject({ skill: 'review', event: 'completed', outcome: 'unknown' });
// Idempotent under count semantics too: started=2, completed=2 → no-op.
expect(runHook(stopPayload()).exitCode).toBe(0);
expect(timelineEntries().filter((e) => e.source === 'stop-hook')).toHaveLength(1);
});
test('exit 0 on missing timeline (nothing written, nothing created)', () => {
const r = runHook(stopPayload());
expect(r.exitCode).toBe(0);
expect(fs.existsSync(timelinePath)).toBe(false);
});
test('exit 0 on a corrupt timeline; corrupt lines are skipped, valid ones still repaired', () => {
fs.writeFileSync(
timelinePath,
[
'this is not json at all {{{',
JSON.stringify({ skill: 'qa', event: 'started', session: '44-4' }),
'{"half": "an object"',
].join('\n') + '\n',
);
const r = runHook(stopPayload());
expect(r.exitCode).toBe(0);
const repairs = timelineEntries().filter((e) => e.source === 'stop-hook');
expect(repairs).toHaveLength(1);
expect(repairs[0].skill).toBe('qa');
});
test('exit 0 on a FULLY corrupt timeline (no valid entries → no write)', () => {
const garbage = 'garbage\n{{{\n';
fs.writeFileSync(timelinePath, garbage);
const r = runHook(stopPayload());
expect(r.exitCode).toBe(0);
expect(fs.readFileSync(timelinePath, 'utf-8')).toBe(garbage);
});
test('exit 0 on garbage stdin', () => {
fs.writeFileSync(
timelinePath,
JSON.stringify({ skill: 'qa', event: 'started', session: '55-5' }) + '\n',
);
const r = runHook('not json');
expect(r.exitCode).toBe(0);
});
test('exit 0 on empty stdin', () => {
expect(runHook('').exitCode).toBe(0);
});
test('tail window (P3): a recent dangling entry in a >256KB timeline is still repaired', () => {
const lines: string[] = [];
// An old dangling entry that falls OUTSIDE the 256KB tail window —
// beyond repair interest by design (its session is long gone).
lines.push(JSON.stringify({ skill: 'review', event: 'started', session: 'old-1' }));
// >512KB of closed pairs pushes the old entry well past the window while
// proving windowed parsing still walks real entries.
let n = 0;
while (lines.length * 100 < 512 * 1024) {
lines.push(
JSON.stringify({ skill: 'qa', event: 'started', session: `pad-${n}`, pad: '#'.repeat(40) }),
);
lines.push(JSON.stringify({ skill: 'qa', event: 'completed', session: `pad-${n}`, outcome: 'success' }));
n++;
}
lines.push(JSON.stringify({ skill: 'ship', event: 'started', session: 'recent-9' }));
fs.writeFileSync(timelinePath, lines.join('\n') + '\n');
expect(fs.statSync(timelinePath).size).toBeGreaterThan(256 * 1024);
const r = runHook(stopPayload());
expect(r.exitCode).toBe(0);
const repairs = timelineEntries().filter((e) => e.source === 'stop-hook');
expect(repairs).toHaveLength(1);
expect(repairs[0]).toMatchObject({
skill: 'ship',
event: 'completed',
outcome: 'unknown',
session: 'recent-9',
});
});
test('oversized timeline is skipped, untouched, and still exits 0 (fail-open size cap)', () => {
const line = JSON.stringify({ skill: 'qa', event: 'started', session: '66-6' }) + '\n';
const filler = '#'.repeat(1024 * 1024);
fs.writeFileSync(timelinePath, line + filler.repeat(11));
const sizeBefore = fs.statSync(timelinePath).size;
const r = runHook(stopPayload());
expect(r.exitCode).toBe(0);
expect(fs.statSync(timelinePath).size).toBe(sizeBefore);
});
});
describe('timeline-stop-hook wiring', () => {
test('setup registers the Stop hook with its own source tag and tears it down on --no-team', () => {
const setup = fs.readFileSync(path.join(ROOT, 'setup'), 'utf-8');
expect(setup).toContain('--event Stop');
expect(setup).toContain('--source gstack-timeline-stop');
expect(setup).toContain('hosts/claude/hooks/timeline-stop-hook');
// --no-team teardown removes it alongside the plan-tune hooks.
const teardown = setup.slice(setup.indexOf('# Also tear down plan-tune'));
expect(teardown).toContain('remove-source --source gstack-timeline-stop');
});
test('gstack-uninstall removes the Stop hook registration', () => {
const uninstall = fs.readFileSync(path.join(ROOT, 'bin', 'gstack-uninstall'), 'utf-8');
expect(uninstall).toContain('remove-source --source gstack-timeline-stop');
});
test('the bash shim is fail-open: exits 0 even when bun is unavailable', () => {
const r = spawnSync('bash', [HOOK], {
input: '{}',
encoding: 'utf-8',
env: { HOME: tmpHome, PATH: '/usr/bin:/bin', GSTACK_HOME: tmpHome },
timeout: 15_000,
});
expect(r.status).toBe(0);
});
});
+257
View File
@@ -0,0 +1,257 @@
/**
* gstack-uninstall: real-directory installs are removed, gated on provenance
* (#2563, F8, ENG-OV10).
*
* On Windows, setup installs skills as REAL directory copies (no symlinks).
* gstack-uninstall's per-skill loop filtered on `[ -L ]`, so every copy was
* skipped: the tool exited 0, printed "gstack uninstalled.", and left ~52
* gstack-* directories behind. The same filter also missed the standard Unix
* shape (real dir + symlinked SKILL.md).
*
* Deletion gate for real-file installs (F8): the directory name must be in
* gstack's skill inventory AND its SKILL.md must carry the existing generated
* banner `<!-- AUTO-GENERATED from` (ENG-OV10 every pre-v1.67 copy already
* carries it; a NEW marker would refuse legitimate old installs). Anything
* that fails a gate is listed to stderr and NEVER deleted.
*/
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';
const ROOT = path.resolve(import.meta.dir, '..');
const UNINSTALL = path.join(ROOT, 'bin', 'gstack-uninstall');
const BANNER = '<!-- AUTO-GENERATED from SKILL.md.tmpl - DO NOT EDIT DIRECTLY -->\n';
function skillMd(name: string, withBanner = true): string {
return `---\nname: ${name}\ndescription: test\n---\n${withBanner ? BANNER : ''}# ${name}\n`;
}
let tmpDir: string;
let mockHome: string;
let skillsDir: string;
let installRoot: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-uninstall-copies-'));
mockHome = path.join(tmpDir, 'home');
skillsDir = path.join(mockHome, '.claude', 'skills');
installRoot = path.join(skillsDir, 'gstack');
// Mock install root: the source-of-truth skill dirs the inventory reads.
for (const skill of ['review', 'ship', 'qa']) {
fs.mkdirSync(path.join(installRoot, skill), { recursive: true });
fs.writeFileSync(path.join(installRoot, skill, 'SKILL.md'), skillMd(skill));
}
fs.writeFileSync(path.join(installRoot, 'SKILL.md'), skillMd('gstack'));
fs.mkdirSync(path.join(mockHome, '.gstack'), { recursive: true });
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
function runUninstall(): { status: number | null; stdout: string; stderr: string } {
const r = spawnSync('bash', [UNINSTALL, '--force'], {
stdio: 'pipe',
encoding: 'utf-8',
env: {
...process.env,
HOME: mockHome,
GSTACK_DIR: installRoot,
GSTACK_STATE_DIR: path.join(mockHome, '.gstack'),
},
cwd: tmpDir, // not a git repo — per-project paths inert
timeout: 20_000,
});
return { status: r.status, stdout: r.stdout, stderr: r.stderr };
}
/** Create a Windows-shape install entry: real dir + real-file SKILL.md. */
function realDirEntry(name: string, content: string): string {
const dir = path.join(skillsDir, name);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'SKILL.md'), content);
return dir;
}
describe('gstack-uninstall removes Windows real-dir copies (#2563)', () => {
test('inventory name + banner → removed (flat, prefixed, and alias forms)', () => {
const review = realDirEntry('review', skillMd('review'));
const prefixedShip = realDirEntry('gstack-ship', skillMd('gstack-ship'));
const alias = realDirEntry('_gstack-command', skillMd('_gstack-command'));
const ogbAlias = realDirEntry('connect-chrome', skillMd('connect-chrome'));
const r = runUninstall();
expect(r.status).toBe(0);
expect(fs.existsSync(review)).toBe(false);
expect(fs.existsSync(prefixedShip)).toBe(false);
expect(fs.existsSync(alias)).toBe(false);
expect(fs.existsSync(ogbAlias)).toBe(false);
expect(fs.existsSync(installRoot)).toBe(false);
});
test('name NOT in inventory → kept and listed to stderr, even with a banner', () => {
const foreign = realDirEntry('my-notes', skillMd('my-notes'));
const r = runUninstall();
expect(r.status).toBe(0);
expect(fs.existsSync(foreign)).toBe(true);
expect(r.stderr).toContain('my-notes');
expect(r.stderr).toContain('left in place');
});
test('no banner → kept and listed, even when the name collides with a gstack skill', () => {
// F8's name-collision row: a user's own hand-written ~/.claude/skills/ship.
const usersOwn = realDirEntry('ship', skillMd('ship', false));
const r = runUninstall();
expect(r.status).toBe(0);
expect(fs.existsSync(usersOwn)).toBe(true);
expect(fs.readFileSync(path.join(usersOwn, 'SKILL.md'), 'utf-8')).toContain('name: ship');
// Separator-insensitive: the bash uninstall prints POSIX paths even on
// Windows (Git Bash), where path.join would demand a backslash.
expect(r.stderr.replace(/\\/g, '/')).toContain('skills/ship');
});
test('real dir without any SKILL.md is untouched and unlisted', () => {
const plain = path.join(skillsDir, 'other-tool');
fs.mkdirSync(plain, { recursive: true });
const r = runUninstall();
expect(r.status).toBe(0);
expect(fs.existsSync(plain)).toBe(true);
expect(r.stderr).not.toContain('other-tool');
});
test('a clean sweep reports the removed entries', () => {
realDirEntry('review', skillMd('review'));
const r = runUninstall();
expect(r.status).toBe(0);
expect(r.stdout).toContain('claude/review');
expect(r.stdout).toContain('gstack uninstalled.');
});
});
// symlinkSync needs Developer Mode on Windows runners; the Unix install shape
// can't be constructed there. The shape is Unix-only in practice anyway.
describe.skipIf(process.platform === 'win32')(
'gstack-uninstall removes the Unix real-dir + symlinked-SKILL.md shape',
() => {
test('SKILL.md symlink pointing into gstack → removed', () => {
const dir = path.join(skillsDir, 'qa');
fs.mkdirSync(dir, { recursive: true });
fs.symlinkSync(path.join(installRoot, 'qa', 'SKILL.md'), path.join(dir, 'SKILL.md'));
const r = runUninstall();
expect(r.status).toBe(0);
expect(fs.existsSync(dir)).toBe(false);
});
test('SKILL.md symlink into a gstack-SUBSTRING path (gstack-fork) → kept and listed', () => {
// DM5: the shape-2 gate must match "gstack" as an anchored path
// segment, not a substring — a user's own skill whose SKILL.md links
// into ~/tools/gstack-fork/ is NOT ours, even when the dir name
// collides with a real gstack skill (here: review, in the inventory).
// The anchored gate only matches a literal /gstack/ path segment, so
// the tmpdir must not carry one (shared-process shard runs can leave
// $TMPDIR pointing into a gstack worktree — same hazard as the
// "pointing elsewhere" test below). Fall back to a fixed neutral root
// and ASSERT the precondition.
let neutralRoot = os.tmpdir();
if (neutralRoot.split(path.sep).includes('gstack')) neutralRoot = '/private' + path.sep + 'tmp';
const forkRoot = fs.mkdtempSync(path.join(neutralRoot, 'tools-'));
expect(forkRoot.split(path.sep).includes('gstack')).toBe(false);
const forkSrc = path.join(forkRoot, 'gstack-fork', 'review');
fs.mkdirSync(forkSrc, { recursive: true });
fs.writeFileSync(path.join(forkSrc, 'SKILL.md'), skillMd('review'));
const dir = path.join(skillsDir, 'review');
fs.mkdirSync(dir, { recursive: true });
fs.symlinkSync(path.join(forkSrc, 'SKILL.md'), path.join(dir, 'SKILL.md'));
try {
const r = runUninstall();
expect(r.status).toBe(0);
expect(fs.existsSync(dir)).toBe(true);
expect(r.stderr).toContain('left in place');
expect(r.stderr).toContain(path.join('skills', 'review'));
} finally {
fs.rmSync(forkRoot, { recursive: true, force: true });
}
});
test('SKILL.md symlink into gstack but name NOT in inventory → kept and listed', () => {
// Shape 2 now carries the same inventory gate as shape 3: a dir whose
// name setup could never have created is skipped even when its
// SKILL.md target resolves into the install root.
const dir = path.join(skillsDir, 'my-custom-wrapper');
fs.mkdirSync(dir, { recursive: true });
fs.symlinkSync(path.join(installRoot, 'qa', 'SKILL.md'), path.join(dir, 'SKILL.md'));
const r = runUninstall();
expect(r.status).toBe(0);
expect(fs.existsSync(dir)).toBe(true);
expect(r.stderr).toContain('my-custom-wrapper');
});
test('SKILL.md symlink pointing elsewhere → kept and listed', () => {
// Target path must not contain a gstack path segment (the provenance
// match is anchored: gstack/*|*/gstack/*; keeping the stricter
// no-substring precondition costs nothing) — the suite
// tmpdir prefix does, so use a separate neutral tmpdir. os.tmpdir()
// reads $TMPDIR at CALL time, and in shared-process shard runs a
// neighboring test can leave it pointing at a gstack-containing path —
// observed once in a full-suite shard (the "neutral" target then
// matched the provenance substring and the dir was wrongly deleted by
// the test's own expectations). Fall back to a fixed neutral root and
// ASSERT neutrality so the precondition can never silently rot.
let neutralRoot = os.tmpdir();
// realpath'd literal /tmp: /private/tmp on macOS, /tmp on Linux. The
// hardcoded '/private/tmp' fallback ENOENT'd on Linux CI, where the
// shard runner's TMPDIR is the gstack-containing path that forces this
// branch. (Never taken on Windows — its TMPDIR carries no 'gstack'.)
if (neutralRoot.includes('gstack')) neutralRoot = fs.realpathSync('/tmp');
const neutral = fs.mkdtempSync(path.join(neutralRoot, 'other-skill-src-'));
expect(neutral.includes('gstack')).toBe(false);
const elsewhere = path.join(neutral, 'elsewhere.md');
fs.writeFileSync(elsewhere, '# not ours\n');
const dir = path.join(skillsDir, 'someone-elses');
fs.mkdirSync(dir, { recursive: true });
fs.symlinkSync(elsewhere, path.join(dir, 'SKILL.md'));
try {
const r = runUninstall();
expect(r.status).toBe(0);
expect(fs.existsSync(dir)).toBe(true);
expect(r.stderr).toContain('someone-elses');
} finally {
fs.rmSync(neutral, { recursive: true, force: true });
}
});
},
);
describe('every installable skill SKILL.md carries the generated banner (ENG-OV10)', () => {
// The uninstall provenance gate is only sound if the banner is universal:
// a bannerless generated skill would be stranded on Windows forever.
test('all top-level skill SKILL.md files contain the AUTO-GENERATED banner', () => {
const missing: string[] = [];
for (const entry of fs.readdirSync(ROOT, { withFileTypes: true })) {
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
const md = path.join(ROOT, entry.name, 'SKILL.md');
if (!fs.existsSync(md)) continue;
if (!fs.readFileSync(md, 'utf-8').includes('<!-- AUTO-GENERATED from')) {
missing.push(entry.name);
}
}
expect(missing).toEqual([]);
});
test('the root router SKILL.md carries the banner too (alias copies inherit it)', () => {
expect(fs.readFileSync(path.join(ROOT, 'SKILL.md'), 'utf-8')).toContain(
'<!-- AUTO-GENERATED from',
);
});
});
+68
View File
@@ -161,5 +161,73 @@ describe('gstack-uninstall', () => {
// Non-gstack should survive
expect(fs.existsSync(path.join(mockHome, '.claude', 'skills', 'other-tool'))).toBe(true);
});
test('--force removes Cursor gstack skills and leaves other Cursor skills', () => {
// Cursor installs are rendered real dirs, so removal is gated on the
// generated banner in SKILL.md (S5) — the managed fixtures carry it.
const banner = '<!-- AUTO-GENERATED from SKILL.md.tmpl - DO NOT EDIT DIRECTLY -->\n# x\n';
fs.mkdirSync(path.join(mockHome, '.cursor', 'skills', 'gstack'), { recursive: true });
fs.writeFileSync(path.join(mockHome, '.cursor', 'skills', 'gstack', 'SKILL.md'), banner);
fs.mkdirSync(path.join(mockHome, '.cursor', 'skills', 'gstack-review'), { recursive: true });
fs.writeFileSync(path.join(mockHome, '.cursor', 'skills', 'gstack-review', 'SKILL.md'), banner);
fs.mkdirSync(path.join(mockHome, '.cursor', 'skills', 'frontend-design'), { recursive: true });
fs.writeFileSync(path.join(mockHome, '.cursor', 'skills', 'frontend-design', 'SKILL.md'), 'keep');
fs.mkdirSync(path.join(mockGitRoot, '.cursor', 'skills', 'gstack-ship'), { recursive: true });
fs.writeFileSync(path.join(mockGitRoot, '.cursor', 'skills', 'gstack-ship', 'SKILL.md'), banner);
fs.mkdirSync(path.join(mockGitRoot, '.cursor', 'rules'), { recursive: true });
fs.writeFileSync(path.join(mockGitRoot, '.cursor', 'rules', 'keep.md'), 'keep');
const result = spawnSync('bash', [UNINSTALL, '--force'], {
stdio: 'pipe',
env: {
...process.env,
HOME: mockHome,
GSTACK_DIR: path.join(mockHome, '.claude', 'skills', 'gstack'),
GSTACK_STATE_DIR: path.join(mockHome, '.gstack'),
},
cwd: mockGitRoot,
});
expect(result.status).toBe(0);
expect(fs.existsSync(path.join(mockHome, '.cursor', 'skills', 'gstack'))).toBe(false);
expect(fs.existsSync(path.join(mockHome, '.cursor', 'skills', 'gstack-review'))).toBe(false);
expect(fs.existsSync(path.join(mockHome, '.cursor', 'skills', 'frontend-design'))).toBe(true);
expect(fs.existsSync(path.join(mockGitRoot, '.cursor', 'skills', 'gstack-ship'))).toBe(false);
expect(fs.existsSync(path.join(mockGitRoot, '.cursor', 'rules', 'keep.md'))).toBe(true);
});
test("a user's own gstack-prefixed Cursor dir (no banner) survives and is listed", () => {
// S5: the bare gstack* glob must not sweep a dir that merely starts
// with "gstack" — provenance comes from the generated banner, and a
// hand-written SKILL.md never carries it.
const foreign = path.join(mockHome, '.cursor', 'skills', 'gstack-fork-notes');
fs.mkdirSync(foreign, { recursive: true });
fs.writeFileSync(path.join(foreign, 'SKILL.md'), '# my own notes\n');
const foreignLocal = path.join(mockGitRoot, '.cursor', 'skills', 'gstack-my-rules');
fs.mkdirSync(foreignLocal, { recursive: true });
fs.writeFileSync(path.join(foreignLocal, 'SKILL.md'), '# hand-written\n');
const result = spawnSync('bash', [UNINSTALL, '--force'], {
stdio: 'pipe',
env: {
...process.env,
HOME: mockHome,
GSTACK_DIR: path.join(mockHome, '.claude', 'skills', 'gstack'),
GSTACK_STATE_DIR: path.join(mockHome, '.gstack'),
},
cwd: mockGitRoot,
});
expect(result.status).toBe(0);
expect(fs.existsSync(foreign)).toBe(true);
expect(fs.existsSync(foreignLocal)).toBe(true);
const stderr = result.stderr.toString();
expect(stderr).toContain('left in place');
expect(stderr).toContain('gstack-fork-notes');
expect(stderr).toContain('gstack-my-rules');
});
});
});
+273
View File
@@ -0,0 +1,273 @@
/**
* :user render never dirties the global-git install (#2569).
*
* Pre-v1.67, gbrain-enabled setups ran `gen:skill-docs:user --host claude`
* IN PLACE inside the install checkout, rewriting ~16 tracked SKILL.md files.
* The checkout stayed permanently dirty and every upgrade stashed a redundant
* snapshot of generated content. The fix renders to an untracked out-dir
* (~/.gstack/render/claude) and makes the Claude installers setup's
* link_claude_skill_dirs AND bin/gstack-relink prefer rendered files when
* present. A one-time migration (v1.67.0.0.sh) restores the legacy dirt.
*
* (The render mechanism itself worktree byte-unchanged, section repointing
* is pinned by test/gen-skill-docs-out-dir.test.ts.)
*/
import { describe, test, expect } 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 CONFIG_SRC = fs.readFileSync(path.join(ROOT, 'bin', 'gstack-config'), 'utf-8');
const RELINK_SRC = fs.readFileSync(path.join(ROOT, 'bin', 'gstack-relink'), 'utf-8');
const MIGRATION = path.join(ROOT, 'gstack-upgrade', 'migrations', 'v1.67.0.0.sh');
function extractFn(src: string, name: string): string {
const start = src.indexOf(`${name}() {`);
const end = src.indexOf('\n}\n', start);
if (start < 0 || end < 0) throw new Error(`Could not locate ${name}()`);
return src.slice(start, end + 2);
}
describe(':user render targets the out-dir, never the checkout (#2569)', () => {
test('setup renders gen:skill-docs:user with --out-dir only', () => {
const sites = SETUP_SRC.split('gen:skill-docs:user').length - 1;
const outDirSites = SETUP_SRC.split('gen:skill-docs:user --host claude --out-dir').length - 1;
// Every executable :user invocation carries --out-dir. (Prose/log
// mentions don't pair with `bun_cmd run`.)
const executableSites = SETUP_SRC.split('run gen:skill-docs:user').length - 1;
expect(executableSites).toBeGreaterThan(0);
expect(outDirSites).toBe(executableSites);
expect(sites).toBeGreaterThanOrEqual(outDirSites);
});
test('setup renders into a TMP dir, swaps on success, repoints after (never wipes first)', () => {
const block = SETUP_SRC.slice(
SETUP_SRC.indexOf('# ─── GBrain detection + conditional SKILL.md render'),
SETUP_SRC.indexOf('# 11. Plan-tune cathedral hook install'),
);
// The live render dir is symlinked into by installed skills — it may only
// be replaced AFTER a successful render (a pre-render wipe left every
// brain-aware SKILL.md link dangling on a transient render failure).
expect(block).toContain('--out-dir "$_GSTACK_RENDER_TMP"');
expect(block).not.toContain('--out-dir "$_GSTACK_RENDER_DIR"');
expect(block).toContain('_swap_in_render "$_GSTACK_RENDER_DIR" "$_GSTACK_RENDER_TMP"');
expect(block).toContain('link_claude_skill_dirs "$SOURCE_GSTACK_DIR" "$INSTALL_SKILLS_DIR"');
// Stale-render cleanup on the gbrain-gone path (a deliberate wipe).
expect(block).toContain('gbrain not detected');
expect(block).toContain('rm -rf "$_GSTACK_RENDER_DIR"');
});
test('gstack-config gbrain-refresh renders to a TMP out-dir, swaps on success', () => {
expect(CONFIG_SRC).toContain('gen:skill-docs:user --host claude --out-dir');
expect(CONFIG_SRC).not.toContain("this dirties the install's git tree");
expect(CONFIG_SRC).toContain('gstack-relink');
expect(CONFIG_SRC).toContain('--out-dir "$RENDER_TMP"');
expect(CONFIG_SRC).not.toContain('--out-dir "$RENDER_DIR"');
expect(CONFIG_SRC).toContain('_swap_in_render "$RENDER_DIR" "$RENDER_TMP"');
});
test('_swap_in_render behavior: success replaces, and the shape means failure never touches the live dir', () => {
// Both files carry the same-contract helper — drive each for real.
for (const src of [SETUP_SRC, CONFIG_SRC]) {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-render-swap-'));
try {
const live = path.join(tmp, 'claude');
const fresh = path.join(tmp, 'claude.tmp.123');
fs.mkdirSync(path.join(live, 'ship'), { recursive: true });
fs.writeFileSync(path.join(live, 'ship', 'SKILL.md'), 'old-render\n');
fs.mkdirSync(path.join(fresh, 'ship'), { recursive: true });
fs.writeFileSync(path.join(fresh, 'ship', 'SKILL.md'), 'new-render\n');
const script = [
'set -e',
extractFn(src, '_swap_in_render'),
`_swap_in_render "${live}" "${fresh}"`,
].join('\n');
const r = spawnSync('bash', ['-c', script], { encoding: 'utf-8', timeout: 15_000 });
expect(r.status).toBe(0);
// Live dir now serves the fresh render at the SAME path (links into
// it stay valid), tmp and .old are gone.
expect(fs.readFileSync(path.join(live, 'ship', 'SKILL.md'), 'utf-8')).toBe('new-render\n');
expect(fs.existsSync(fresh)).toBe(false);
expect(fs.readdirSync(tmp)).toEqual(['claude']);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
}
});
test('a FAILED render leaves the previous render dir fully intact (gstack-config path, end-to-end shape)', () => {
// Reconstruct the exact failure branch: render into tmp fails → tmp is
// removed, the live dir (and the symlinks into it) are untouched, and
// _swap_in_render is never called.
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-render-fail-'));
try {
const live = path.join(tmp, 'claude');
fs.mkdirSync(path.join(live, 'ship'), { recursive: true });
fs.writeFileSync(path.join(live, 'ship', 'SKILL.md'), 'previous-render\n');
// An installed skill symlinks into the live render dir.
const installed = path.join(tmp, 'installed-ship-SKILL.md');
fs.symlinkSync(path.join(live, 'ship', 'SKILL.md'), installed);
const script = [
'set -u',
extractFn(CONFIG_SRC, '_swap_in_render'),
`RENDER_DIR="${live}"`,
'RENDER_TMP="$RENDER_DIR.tmp.$$"',
'rm -rf "$RENDER_TMP"',
// The render fails (broken template, bun error, disk full).
'if ( mkdir -p "$RENDER_TMP" && false ); then',
' _swap_in_render "$RENDER_DIR" "$RENDER_TMP"',
'else',
' rm -rf "$RENDER_TMP"',
' echo "render failed — previous render left in place" >&2',
'fi',
].join('\n');
const r = spawnSync('bash', ['-c', script], { encoding: 'utf-8', timeout: 15_000 });
expect(r.status).toBe(0);
expect(fs.readFileSync(path.join(live, 'ship', 'SKILL.md'), 'utf-8')).toBe('previous-render\n');
// The installed symlink still resolves — the skill set did not vanish.
expect(fs.readFileSync(installed, 'utf-8')).toBe('previous-render\n');
expect(fs.readdirSync(tmp).sort()).toEqual(['claude', 'installed-ship-SKILL.md']);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test('gstack-relink prefers the render dir when a rendered SKILL.md exists', () => {
expect(RELINK_SRC).toContain('render/claude');
expect(RELINK_SRC).toContain('[ -f "$RENDER_DIR/$skill/SKILL.md" ] && skill_md_src="$RENDER_DIR/$skill/SKILL.md"');
});
});
describe('link_claude_skill_dirs prefers rendered SKILL.md (behavior)', () => {
test('a rendered variant is served; skills without one fall back to source', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-render-pref-'));
try {
const src = path.join(tmp, 'src');
const skills = path.join(tmp, 'skills');
const home = path.join(tmp, 'gstack-home');
// Source tree: two skills.
for (const s of ['alpha', 'beta']) {
fs.mkdirSync(path.join(src, s), { recursive: true });
fs.writeFileSync(
path.join(src, s, 'SKILL.md'),
`---\nname: ${s}\ndescription: t\n---\ncanonical-${s}\n`,
);
}
// Render exists for alpha only.
fs.mkdirSync(path.join(home, 'render', 'claude', 'alpha'), { recursive: true });
fs.writeFileSync(
path.join(home, 'render', 'claude', 'alpha', 'SKILL.md'),
'---\nname: alpha\ndescription: t\n---\nrendered-alpha with Brain Context Load\n',
);
fs.mkdirSync(skills, { recursive: true });
const script = [
'set -e',
'IS_WINDOWS=0',
'SKILL_PREFIX=0',
'_WINDOWS_COPY_NOTE_PRINTED=1',
`GSTACK_HOME="${home}"`,
extractFn(SETUP_SRC, '_link_or_copy'),
extractFn(SETUP_SRC, '_print_windows_copy_note_once'),
extractFn(SETUP_SRC, '_link_skill_runtime_assets'),
extractFn(SETUP_SRC, 'link_claude_skill_dirs'),
`link_claude_skill_dirs "${src}" "${skills}"`,
].join('\n');
const r = spawnSync('bash', ['-c', script], { encoding: 'utf-8', timeout: 15_000 });
expect(r.status).toBe(0);
expect(fs.readFileSync(path.join(skills, 'alpha', 'SKILL.md'), 'utf-8')).toContain('rendered-alpha');
expect(fs.readFileSync(path.join(skills, 'beta', 'SKILL.md'), 'utf-8')).toContain('canonical-beta');
// The SOURCE stayed canonical — the render is served via the link only.
expect(fs.readFileSync(path.join(src, 'alpha', 'SKILL.md'), 'utf-8')).toContain('canonical-alpha');
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
});
describe('migration v1.67.0.0 — legacy in-place render cleanup (F12)', () => {
function git(cwd: string, ...args: string[]): void {
const r = spawnSync('git', args, { cwd, encoding: 'utf-8' });
if (r.status !== 0) throw new Error(`git ${args.join(' ')} failed: ${r.stderr}`);
}
function makeLegacyInstall(tmp: string): string {
const install = path.join(tmp, 'install');
fs.mkdirSync(path.join(install, 'ship', 'sections'), { recursive: true });
fs.writeFileSync(path.join(install, 'VERSION'), '1.66.0.0\n');
fs.writeFileSync(path.join(install, 'ship', 'SKILL.md'), 'canonical ship\n');
fs.writeFileSync(path.join(install, 'ship', 'sections', 'tests.md'), 'canonical section\n');
fs.writeFileSync(path.join(install, 'README.md'), 'readme\n');
git(install, 'init', '-b', 'main');
git(install, 'config', 'user.email', 't@t.test');
git(install, 'config', 'user.name', 't');
git(install, 'add', '-A');
git(install, 'commit', '-m', 'base', '-q');
return install;
}
function runMigration(install: string): { status: number | null; stdout: string } {
const r = spawnSync('bash', [MIGRATION], {
encoding: 'utf-8',
env: { ...process.env, GSTACK_INSTALL_DIR: install },
timeout: 15_000,
});
return { status: r.status, stdout: r.stdout };
}
test('restores render-class dirt, leaves user changes alone, idempotent', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-migration-'));
try {
const install = makeLegacyInstall(tmp);
// Legacy render dirt + a genuine user edit + an untracked file.
fs.writeFileSync(path.join(install, 'ship', 'SKILL.md'), 'brain-aware rendered ship\n');
fs.writeFileSync(path.join(install, 'ship', 'sections', 'tests.md'), 'brain-aware section\n');
fs.writeFileSync(path.join(install, 'README.md'), 'user edit\n');
fs.writeFileSync(path.join(install, 'notes.txt'), 'untracked\n');
const r1 = runMigration(install);
expect(r1.status).toBe(0);
expect(r1.stdout).toContain('restored 2 tracked file(s)');
expect(fs.readFileSync(path.join(install, 'ship', 'SKILL.md'), 'utf-8')).toBe('canonical ship\n');
expect(fs.readFileSync(path.join(install, 'ship', 'sections', 'tests.md'), 'utf-8')).toBe('canonical section\n');
// The user's own edits are NOT the render footprint — untouched, reported.
expect(fs.readFileSync(path.join(install, 'README.md'), 'utf-8')).toBe('user edit\n');
expect(fs.existsSync(path.join(install, 'notes.txt'))).toBe(true);
expect(r1.stdout).toContain('left');
// Idempotent: nothing left in the footprint on the second run.
const r2 = runMigration(install);
expect(r2.status).toBe(0);
expect(r2.stdout).not.toContain('restored');
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test('clean checkout, missing dir, and symlinked install are all silent no-ops', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-migration-noop-'));
try {
const install = makeLegacyInstall(tmp);
const clean = runMigration(install);
expect(clean.status).toBe(0);
expect(clean.stdout.trim()).toBe('');
const missing = runMigration(path.join(tmp, 'does-not-exist'));
expect(missing.status).toBe(0);
const link = path.join(tmp, 'symlinked-install');
fs.symlinkSync(install, link);
const sym = runMigration(link);
expect(sym.status).toBe(0);
expect(sym.stdout.trim()).toBe('');
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
});