Merge remote-tracking branch 'origin/main' into phantom-askuserquestion-hooks

# Conflicts:
#	CHANGELOG.md
#	TODOS.md
#	VERSION
#	bin/gstack-settings-hook
#	package.json
#	setup
This commit is contained in:
Garry Tan
2026-08-19 14:09:30 -07:00
137 changed files with 6664 additions and 666 deletions
+304 -2
View File
@@ -6,6 +6,7 @@ import * as path from "path";
import {
toMsysPath,
slugFromEnvironment,
outermostProjectRoot,
resolveSlug,
NEEDS_NATIVE_SLUG_ON_WINDOWS,
} from "../lib/bin-context";
@@ -14,8 +15,21 @@ 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 {} });
let savedEnvSlug: string | undefined;
beforeEach(() => {
// realpathSync so the native cwd matches what the bash script's `pwd` reports
// (macOS: /var/folders/... is a symlink to /private/var/folders/...).
tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "gstack-slug-")));
// An ambient GSTACK_PROJECT_SLUG (leaked from an operator shell or a sibling
// test in a shared-process shard) would override every derivation under test.
savedEnvSlug = process.env.GSTACK_PROJECT_SLUG;
delete process.env.GSTACK_PROJECT_SLUG;
});
afterEach(() => {
try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {}
if (savedEnvSlug === undefined) delete process.env.GSTACK_PROJECT_SLUG;
else process.env.GSTACK_PROJECT_SLUG = savedEnvSlug;
});
/**
* Windows cannot exec bin/gstack-slug -- a `#!/usr/bin/env bash` script with no file
@@ -111,3 +125,291 @@ describe("the fallback stays win32-gated", () => {
}
});
});
/**
* Walk-up parity: the native fallback must resolve the same OUTERMOST project
* root as bin/gstack-slug's `_outermost_project_root` (see
* test/gstack-slug-cwd-walk-up.test.ts for the bash-side pins). Before this
* port, the native path derived the slug from `git remote get-url origin` in
* cwd — the INNERMOST repo — so a Windows session inside a nested/vendored
* repo or an artifact-only subdir filed its state under a different slug than
* every bash-side consumer.
*
* Each scenario is run through BOTH implementations on the same fixture
* (separate GSTACK_HOMEs so neither reads the other's cache) and pinned to the
* same expected slug. The bash leg is skipped on win32, where bash isn't
* reliably spawnable — the native leg still pins the ported semantics there.
*/
describe("walk-up parity with bin/gstack-slug (outermost project root)", () => {
const SCRIPT = path.join(ROOT, "bin", "gstack-slug");
const HAS_BASH = process.platform !== "win32";
function bashSlug(cwd: string, extraEnv: Record<string, string> = {}): string {
const env: Record<string, string | undefined> = {
...process.env,
HOME: path.join(tmp, "bash-home"),
GSTACK_HOME: path.join(tmp, "bash-home", ".gstack"),
};
delete env.GSTACK_PROJECT_SLUG; // only set when a scenario passes it explicitly
Object.assign(env, extraEnv);
const r = spawnSync("bash", [SCRIPT], { cwd, env, encoding: "utf-8", timeout: 10_000 });
const m = (r.stdout || "").match(/^SLUG=([^\n]*)$/m);
return m ? m[1] : "";
}
const nativeHome = () => path.join(tmp, "native-home");
/** Assert native === expected, and bash === expected where bash is available. */
function expectBoth(cwd: string, expected: string) {
expect(slugFromEnvironment(nativeHome(), cwd)).toBe(expected);
if (HAS_BASH) expect(bashSlug(cwd)).toBe(expected);
}
test("AC-1: .git at root, artifact-only subdir — slug is the ROOT basename", () => {
const projectRoot = path.join(tmp, "loadout");
const siteSubdir = path.join(projectRoot, "site");
fs.mkdirSync(path.join(projectRoot, ".git"), { recursive: true });
fs.mkdirSync(path.join(siteSubdir, ".vercel"), { recursive: true });
fs.writeFileSync(path.join(siteSubdir, ".vercel", "project.json"), "{}\n");
expectBoth(siteSubdir, "loadout");
});
test("AC-1 variant: package.json at root, node_modules-only subdir — ROOT basename", () => {
const projectRoot = path.join(tmp, "monorepo");
const subdir = path.join(projectRoot, "packages", "web");
fs.mkdirSync(subdir, { recursive: true });
fs.writeFileSync(path.join(projectRoot, "package.json"), "{}\n");
fs.mkdirSync(path.join(subdir, "node_modules"), { recursive: true });
expectBoth(subdir, "monorepo");
});
test("AC-2: stale cache (old-bug shape) self-heals to the outermost-root slug", () => {
const projectRoot = path.join(tmp, "loadout");
const siteSubdir = path.join(projectRoot, "site");
fs.mkdirSync(path.join(projectRoot, ".git"), { recursive: true });
fs.mkdirSync(path.join(siteSubdir, ".vercel"), { recursive: true });
// Pre-seed the native cache with the WRONG value (pre-walk-up poisoning:
// cached == basename(pwd) while pwd is NOT the project root).
const cacheDir = path.join(nativeHome(), "slug-cache");
fs.mkdirSync(cacheDir, { recursive: true });
const cacheFile = path.join(cacheDir, toMsysPath(siteSubdir).replace(/\//g, "_"));
fs.writeFileSync(cacheFile, "site");
expect(slugFromEnvironment(nativeHome(), siteSubdir)).toBe("loadout");
// The cache file itself must have been overwritten (self-healing).
expect(fs.readFileSync(cacheFile, "utf-8")).toBe("loadout");
});
test("sticky cache (#2212): a cached identity that is NOT the old-bug shape survives", () => {
const projectRoot = path.join(tmp, "renamed-project");
fs.mkdirSync(path.join(projectRoot, ".git"), { recursive: true });
const cacheDir = path.join(nativeHome(), "slug-cache");
fs.mkdirSync(cacheDir, { recursive: true });
const cacheFile = path.join(cacheDir, toMsysPath(projectRoot).replace(/\//g, "_"));
fs.writeFileSync(cacheFile, "legacy-name");
// cached != basename(pwd), so the sticky rule holds — no recompute.
expect(slugFromEnvironment(nativeHome(), projectRoot)).toBe("legacy-name");
});
test("AC-3: cwd IS the project root with .git — slug = basename", () => {
const projectRoot = path.join(tmp, "myproject");
fs.mkdirSync(path.join(projectRoot, ".git"), { recursive: true });
expectBoth(projectRoot, "myproject");
});
test("AC-4: no markers anywhere on the chain — slug = pwd basename (fallback)", () => {
const deep = path.join(tmp, "just", "a", "plain", "folder");
fs.mkdirSync(deep, { recursive: true });
expectBoth(deep, "folder");
});
test("AC-5: subdir of a repo with a remote — slug derived from the ROOT's remote", () => {
// Pins the `git -C "$PROJECT_ROOT"` port: the subdir has no repo of its
// own, so the old native path (git in cwd) also reached the parent repo —
// but only the walk-up guarantees BOTH implementations root the remote
// lookup at the same directory.
const projectRoot = path.join(tmp, "realgit");
const subdir = path.join(projectRoot, "src", "deep");
fs.mkdirSync(subdir, { recursive: true });
spawnSync("git", ["init", "-q", projectRoot]);
spawnSync("git", ["-C", projectRoot, "remote", "add", "origin", "https://github.com/foo/bar.git"]);
expectBoth(subdir, "foo-bar");
});
test("weak marker: README.md at root, artifact-only subdir — ROOT basename", () => {
const projectRoot = path.join(tmp, "loadout");
const siteSubdir = path.join(projectRoot, "site");
fs.mkdirSync(siteSubdir, { recursive: true });
fs.writeFileSync(path.join(projectRoot, "README.md"), "# loadout\n");
fs.mkdirSync(path.join(siteSubdir, ".vercel"), { recursive: true });
expectBoth(siteSubdir, "loadout");
});
test("two-tier: vendored sub-repo with .git wins over parent README (strong > weak)", () => {
const projectRoot = path.join(tmp, "loadout");
const subRepo = path.join(projectRoot, "starter-pack");
fs.mkdirSync(subRepo, { recursive: true });
fs.writeFileSync(path.join(projectRoot, "README.md"), "# loadout\n");
fs.mkdirSync(path.join(subRepo, ".git"), { recursive: true });
expectBoth(subRepo, "starter-pack");
});
test("two-tier: outermost weak wins when no strong marker exists on the chain", () => {
const projectRoot = path.join(tmp, "loadout");
const subdir = path.join(projectRoot, "docs");
fs.mkdirSync(subdir, { recursive: true });
fs.writeFileSync(path.join(projectRoot, "README.md"), "# loadout\n");
fs.writeFileSync(path.join(subdir, "README.md"), "# docs\n");
expectBoth(subdir, "loadout");
});
test("nested repo: outermost .git wins — nested/vendored repos don't split stores", () => {
// THE bug this port fixes: the old native path asked the INNERMOST repo's
// remote. bin/gstack-slug resolves the OUTERMOST strong marker instead.
const outer = path.join(tmp, "outer-project");
const inner = path.join(outer, "vendor", "inner-lib");
fs.mkdirSync(inner, { recursive: true });
spawnSync("git", ["init", "-q", outer]);
spawnSync("git", ["-C", outer, "remote", "add", "origin", "git@github.com:acme/outer.git"]);
spawnSync("git", ["init", "-q", inner]);
spawnSync("git", ["-C", inner, "remote", "add", "origin", "git@github.com:vendor/inner.git"]);
expectBoth(inner, "acme-outer");
});
test("LIVE BUG SHAPE: a stray empty .git ancestor no longer degrades the slug (remote-first)", () => {
// The exact 2026-08-17 reproduction: an ancestor dir with an empty .git
// (not a valid repo, no origin) above a canonical-remote repo. The
// pre-remote-first native path resolved PROJECT_ROOT to the stray marker
// ancestor, found no origin THERE, and degraded to its basename — filing
// every repo under it into one shared ~/.gstack/projects/<basename>/.
const strayHome = path.join(tmp, "strayhome");
fs.mkdirSync(path.join(strayHome, ".git"), { recursive: true }); // empty — invalid repo
const repo = path.join(strayHome, "work", "repo");
fs.mkdirSync(repo, { recursive: true });
spawnSync("git", ["init", "-q", repo]);
spawnSync("git", ["-C", repo, "remote", "add", "origin", "https://github.com/garrytan/gstack"]);
expectBoth(repo, "garrytan-gstack");
expect(slugFromEnvironment(nativeHome(), repo)).not.toBe("strayhome");
});
test("nested repos under a stray marker: a no-remote outer cannot shadow an inner remote", () => {
// Outermost REMOTE-bearing repo wins — an outer repo whose origin does
// not resolve is skipped by the remote walk, so the inner remote-bearing
// repo carries identity (parity with bin/gstack-slug's _outermost_remote_repo).
const outer = path.join(tmp, "outer-plain");
const inner = path.join(outer, "vendor", "inner-lib");
fs.mkdirSync(inner, { recursive: true });
spawnSync("git", ["init", "-q", outer]); // no origin — marker-only repo
spawnSync("git", ["init", "-q", inner]);
spawnSync("git", ["-C", inner, "remote", "add", "origin", "git@github.com:vendor/inner.git"]);
expectBoth(inner, "vendor-inner");
});
test("degraded-ancestor cache self-heals: the pre-remote-first cached value is rewritten", () => {
// Pre-fix, the resolver cached basename(PROJECT_ROOT) for the stray
// marker ancestor. cached == the marker root's basename while a
// remote-bearing repo BELOW it exists → recompute + heal the cache.
const strayHome = path.join(tmp, "strayhome");
fs.mkdirSync(path.join(strayHome, ".git"), { recursive: true });
const repo = path.join(strayHome, "git", "proj");
fs.mkdirSync(repo, { recursive: true });
spawnSync("git", ["init", "-q", repo]);
spawnSync("git", ["-C", repo, "remote", "add", "origin", "https://github.com/garrytan/gstack"]);
const cacheDir = path.join(nativeHome(), "slug-cache");
fs.mkdirSync(cacheDir, { recursive: true });
const cacheFile = path.join(cacheDir, toMsysPath(repo).replace(/\//g, "_"));
fs.writeFileSync(cacheFile, "strayhome"); // the degraded value the old resolver cached
expect(slugFromEnvironment(nativeHome(), repo)).toBe("garrytan-gstack");
// The cache file itself must have been overwritten (self-healing).
expect(fs.readFileSync(cacheFile, "utf-8")).toBe("garrytan-gstack");
});
test("package.json wrapper root (no .git): sticky basename slug is PRESERVED — heal is stray-repo-shape only", () => {
// Legit #2212 shape: a monorepo wrapper anchored by package.json used
// gstack before an inner dir grew a remote-bearing repo. The degraded-
// ancestor heal must NOT fire — it is restricted to marker roots anchored
// by a .git entry whose origin does NOT resolve (the live-bug shape).
const wrapper = path.join(tmp, "wrapperproj");
const inner = path.join(wrapper, "apps", "web");
fs.mkdirSync(inner, { recursive: true });
fs.writeFileSync(path.join(wrapper, "package.json"), '{"name":"wrapper"}\n');
spawnSync("git", ["init", "-q", inner]);
spawnSync("git", ["-C", inner, "remote", "add", "origin", "https://github.com/acme/web.git"]);
const cacheDir = path.join(nativeHome(), "slug-cache");
fs.mkdirSync(cacheDir, { recursive: true });
const cacheFile = path.join(cacheDir, toMsysPath(inner).replace(/\//g, "_"));
fs.writeFileSync(cacheFile, "wrapperproj"); // legit sticky identity
expect(slugFromEnvironment(nativeHome(), inner)).toBe("wrapperproj"); // NOT healed to acme-web
expect(fs.readFileSync(cacheFile, "utf-8")).toBe("wrapperproj");
// The bash implementation agrees on the same fixture (own home, seeded cache).
if (HAS_BASH) {
const bashCacheDir = path.join(tmp, "bash-home", ".gstack", "slug-cache");
fs.mkdirSync(bashCacheDir, { recursive: true });
fs.writeFileSync(path.join(bashCacheDir, toMsysPath(inner).replace(/\//g, "_")), "wrapperproj");
expect(bashSlug(inner)).toBe("wrapperproj");
}
});
test("sticky identity preserved (#2212): a remote adopted AT the marker root is NOT healed", () => {
// Legit sticky shape: the repo that adopted the remote IS the marker root
// (remote root == project root), so the degraded-ancestor heal must not
// fire even though cached == basename(project root).
const repo = path.join(tmp, "stickyproj");
fs.mkdirSync(repo, { recursive: true });
spawnSync("git", ["init", "-q", repo]);
spawnSync("git", ["-C", repo, "remote", "add", "origin", "https://github.com/x/y.git"]);
const cacheDir = path.join(nativeHome(), "slug-cache");
fs.mkdirSync(cacheDir, { recursive: true });
const cacheFile = path.join(cacheDir, toMsysPath(repo).replace(/\//g, "_"));
fs.writeFileSync(cacheFile, "stickyproj"); // pre-origin basename identity
expect(slugFromEnvironment(nativeHome(), repo)).toBe("stickyproj");
expect(fs.readFileSync(cacheFile, "utf-8")).toBe("stickyproj");
});
test('hostile origin `url = ..` never becomes a dot slug — basename fallback (dot-only guard)', () => {
// git accepts `..` as a remote URL. Unchecked, the derived slug would be
// ".." — path traversal one level above ~/.gstack/projects/. Both
// implementations must reject it and fall through to the basename.
const repo = path.join(tmp, "dotty");
fs.mkdirSync(repo, { recursive: true });
spawnSync("git", ["init", "-q", repo]);
spawnSync("git", ["-C", repo, "remote", "add", "origin", ".."]);
expectBoth(repo, "dotty");
});
test("GSTACK_PROJECT_SLUG env override beats every other resolution path, never cached", () => {
const projectRoot = path.join(tmp, "loadout");
const siteSubdir = path.join(projectRoot, "site");
fs.mkdirSync(path.join(projectRoot, ".git"), { recursive: true });
fs.mkdirSync(siteSubdir, { recursive: true });
process.env.GSTACK_PROJECT_SLUG = "custom-override";
try {
expect(slugFromEnvironment(nativeHome(), siteSubdir)).toBe("custom-override");
if (HAS_BASH) {
expect(bashSlug(siteSubdir, { GSTACK_PROJECT_SLUG: "custom-override" })).toBe("custom-override");
}
} finally {
delete process.env.GSTACK_PROJECT_SLUG;
}
// Per-invocation escape hatch, never a durable identity: no cache written.
const cacheFile = path.join(nativeHome(), "slug-cache", toMsysPath(siteSubdir).replace(/\//g, "_"));
expect(fs.existsSync(cacheFile)).toBe(false);
});
test("outermostProjectRoot terminates on hostile path forms (dirname fixed points)", () => {
// Mirrors the windows-free-tests regression on the bash side: mixed-form
// paths must hit the dirname fixed point, not loop. A hang here would trip
// the suite timeout; reaching the assertions IS the pass.
for (const hostile of ["C:/Users/nobody/project", ".", "//server/share/dir"]) {
expect(typeof outermostProjectRoot(hostile)).toBe("string");
}
});
});
+9 -4
View File
@@ -166,7 +166,12 @@ describe('brain-cache endpoint detection', () => {
expect(outer).not.toBe('local');
});
test('detectEndpointHash still prefers user scope over project scope (#2499)', async () => {
test('detectEndpointHash prefers project-local scope over user scope (#2392 wave)', async () => {
// Empirically verified against claude 2.1.233 with hermetic fixtures:
// `claude mcp get gbrain` reports "Scope: Local config" when both scopes
// define the server — project-local WINS. The old pin here encoded the
// opposite (user-first) assumption, which mis-hashed endpoints whenever
// the two scopes disagreed.
const mod = await importCache();
const cj = join(TMP_HOME, 'claude.json');
writeFileSync(cj, JSON.stringify({
@@ -175,14 +180,14 @@ describe('brain-cache endpoint detection', () => {
'/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.
const conflictHash = mod.detectEndpointHash(cj, '/w/repo');
// Same file minus the USER entry → identical hash proves project 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);
expect(mod.detectEndpointHash(cj, '/w/repo')).toBe(conflictHash);
});
});
+60 -1
View File
@@ -11,7 +11,10 @@
* Gate-tier, free, pure import + assertion. Runs in <100ms.
*/
import { describe, test, expect } from 'bun:test';
import { describe, test, expect, afterAll } from 'bun:test';
import { mkdtempSync, writeFileSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import {
BRAIN_CACHE_ENTITIES,
SKILL_DIGEST_SUBSETS,
@@ -167,3 +170,59 @@ describe('brain-cache-spec internal consistency', () => {
expect(getPreflightSkills().sort()).toEqual(expected.sort());
});
});
describe('brain-cache MCP scope precedence (C15 pin)', () => {
// Claude Code resolves a same-name MCP conflict in favor of the
// PROJECT-LOCAL entry (.projects[cwd].mcpServers) over the user-scope
// entry (.mcpServers). Verified empirically against claude 2.1.233 with a
// hermetic fake $HOME: `claude mcp get gbrain` reported "Scope: Local
// config" and the project-local URL when both scopes defined gbrain.
// detectEndpointHash must hash the endpoint the project actually talks
// to, or a brain switch would never invalidate the cache.
const TMP = mkdtempSync(join(tmpdir(), 'brain-cache-precedence-'));
afterAll(() => rmSync(TMP, { recursive: true, force: true }));
const cache = () => import('../bin/gstack-brain-cache');
const writeFixture = (name: string, cfg: object): string => {
const p = join(TMP, name);
writeFileSync(p, JSON.stringify(cfg));
return p;
};
const USER_URL = { type: 'http', url: 'https://user.example/mcp' };
const PROJ_URL = { type: 'http', url: 'https://proj.example/mcp' };
test('project-local gbrain entry beats user scope for a cwd inside the project', async () => {
const mod = await cache();
const conflict = writeFixture('claude-conflict.json', {
mcpServers: { gbrain: USER_URL },
projects: { '/w/repo': { mcpServers: { gbrain: PROJ_URL } } },
});
const conflictHash = mod.detectEndpointHash(conflict, '/w/repo/src');
// Same hash as the project entry alone → the project-local entry won.
const projOnly = writeFixture('claude-proj-only.json', {
projects: { '/w/repo': { mcpServers: { gbrain: PROJ_URL } } },
});
expect(conflictHash).toBe(mod.detectEndpointHash(projOnly, '/w/repo/src'));
// And NOT the user entry's hash.
const userOnly = writeFixture('claude-user-only.json', {
mcpServers: { gbrain: USER_URL },
});
expect(conflictHash).not.toBe(mod.detectEndpointHash(userOnly, '/w/repo/src'));
});
test('user scope still resolves when the cwd has no project-local entry', async () => {
const mod = await cache();
const cj = writeFixture('claude-user-fallback.json', {
mcpServers: { gbrain: USER_URL },
projects: { '/other/repo': { mcpServers: { gbrain: PROJ_URL } } },
});
const hash = mod.detectEndpointHash(cj, '/w/unrelated');
expect(hash).toHaveLength(8);
// Matches the user-only hash — the OTHER project's entry is invisible
// outside its own tree.
const userOnly = writeFixture('claude-user-only-2.json', {
mcpServers: { gbrain: USER_URL },
});
expect(hash).toBe(mod.detectEndpointHash(userOnly, '/w/unrelated'));
});
});
+8 -4
View File
@@ -48,11 +48,15 @@ describe('gstack-brain-sync — Windows path/exec invariants', () => {
expect(SRC.indexOf(CR_STRIP)).toBeLessThan(SRC.indexOf('add -f -- "$p"'));
});
test('inline enqueue appends one atomic record at a time (codex P2 #1)', () => {
expect(SRC).toContain('os.O_APPEND');
expect(SRC).toContain('os.write(fd');
// No buffered batch write to the queue (the interleave-corruption shape).
test('inline enqueue writes one atomic record at a time (codex P2 #1, spool form)', () => {
// The invariant is per-record write atomicity (no interleave corruption).
// Pre-spool this was O_APPEND on the shared queue file; the spool design
// satisfies it more strongly: one FILE per record, tmp write + atomic
// os.replace — nothing shared to interleave.
expect(SRC).toContain('os.replace(tmp');
// No shared-file append anywhere (the interleave-corruption shape).
expect(SRC).not.toContain('open(queue_path, "a"');
expect(SRC).not.toContain('os.O_APPEND');
});
test('skip-list is normalized on BOTH discover and drain sides (codex P2 #2)', () => {
+318 -38
View File
@@ -51,6 +51,28 @@ function git(args: string[], cwd?: string) {
return { stdout: res.stdout || '', stderr: res.stderr || '', status: res.status ?? -1 };
}
// ---- spool helpers (maildir-style queue: one FILE per record) ----
// Writers create <epoch>-<pid>-<uniq>.json under .brain-queue.d/ via tmp +
// atomic rename; the drain deletes exactly the files it snapshotted. The
// legacy single-file .brain-queue.jsonl exists only as a migration source.
const spoolDir = () => path.join(tmpHome, '.brain-queue.d');
const spoolFiles = () =>
fs.existsSync(spoolDir())
? fs.readdirSync(spoolDir()).filter((f) => f.endsWith('.json')).sort()
: [];
const spoolText = () =>
spoolFiles()
.map((f) => fs.readFileSync(path.join(spoolDir(), f), 'utf-8'))
.join('');
let spoolSeq = 0;
function seedSpool(record: string): string {
fs.mkdirSync(spoolDir(), { recursive: true });
spoolSeq += 1;
const name = `${Math.floor(Date.now() / 1000)}-${process.pid}-t${spoolSeq}.json`;
fs.writeFileSync(path.join(spoolDir(), name), record.endsWith('\n') ? record : record + '\n');
return name;
}
beforeEach(() => {
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'brain-sync-home-'));
bareRemote = fs.mkdtempSync(path.join(os.tmpdir(), 'brain-sync-remote-'));
@@ -130,6 +152,7 @@ describe('gstack-brain-enqueue', () => {
test('no-op when feature not initialized', () => {
const r = run(['gstack-brain-enqueue', 'projects/foo/learnings.jsonl']);
expect(r.status).toBe(0);
expect(fs.existsSync(spoolDir())).toBe(false);
expect(fs.existsSync(path.join(tmpHome, '.brain-queue.jsonl'))).toBe(false);
});
@@ -137,18 +160,22 @@ describe('gstack-brain-enqueue', () => {
fs.mkdirSync(path.join(tmpHome, '.git'), { recursive: true });
const r = run(['gstack-brain-enqueue', 'projects/foo/learnings.jsonl']);
expect(r.status).toBe(0);
expect(fs.existsSync(path.join(tmpHome, '.brain-queue.jsonl'))).toBe(false);
expect(fs.existsSync(spoolDir())).toBe(false);
});
test('enqueues when mode is full and .git exists', () => {
test('enqueues one spool file when mode is full and .git exists', () => {
fs.mkdirSync(path.join(tmpHome, '.git'), { recursive: true });
run(['gstack-config', 'set', 'artifacts_sync_mode', 'full']);
run(['gstack-brain-enqueue', 'projects/foo/learnings.jsonl']);
const queue = fs.readFileSync(path.join(tmpHome, '.brain-queue.jsonl'), 'utf-8');
expect(queue).toContain('projects/foo/learnings.jsonl');
const obj = JSON.parse(queue.trim());
const files = spoolFiles();
expect(files.length).toBe(1);
// Sortable maildir name: <epoch>-<pid>-<uniq>.json.
expect(files[0]).toMatch(/^\d+-\d+-\d+\.json$/);
const obj = JSON.parse(fs.readFileSync(path.join(spoolDir(), files[0]), 'utf-8').trim());
expect(obj.file).toBe('projects/foo/learnings.jsonl');
expect(obj.ts).toBeTruthy();
// No tmp-file droppings left behind.
expect(fs.readdirSync(spoolDir()).filter((f) => f.startsWith('.tmp-')).length).toBe(0);
});
test('skip list honored', () => {
@@ -157,12 +184,11 @@ describe('gstack-brain-enqueue', () => {
fs.writeFileSync(path.join(tmpHome, '.brain-skip.txt'), 'projects/foo/secret.jsonl\n');
run(['gstack-brain-enqueue', 'projects/foo/secret.jsonl']);
run(['gstack-brain-enqueue', 'projects/foo/ok.jsonl']);
const queue = fs.readFileSync(path.join(tmpHome, '.brain-queue.jsonl'), 'utf-8');
expect(queue).not.toContain('secret.jsonl');
expect(queue).toContain('ok.jsonl');
expect(spoolText()).not.toContain('secret.jsonl');
expect(spoolText()).toContain('ok.jsonl');
});
test('concurrent enqueues all land (atomic append)', async () => {
test('concurrent enqueues all land (one spool file per record)', async () => {
fs.mkdirSync(path.join(tmpHome, '.git'), { recursive: true });
run(['gstack-config', 'set', 'artifacts_sync_mode', 'full']);
const procs = [];
@@ -176,9 +202,10 @@ describe('gstack-brain-enqueue', () => {
}));
}
await Promise.all(procs);
const queue = fs.readFileSync(path.join(tmpHome, '.brain-queue.jsonl'), 'utf-8');
const lines = queue.trim().split('\n').filter(Boolean);
expect(lines.length).toBe(10);
expect(spoolFiles().length).toBe(10);
for (let i = 0; i < 10; i++) {
expect(spoolText()).toContain(`file-${i}.jsonl`);
}
});
test('no args does not crash', () => {
@@ -366,9 +393,8 @@ describe('gstack-brain-sync egress receipt gate', () => {
expect(refused.stderr).toContain('EGRESS_RECEIPT_FAILED');
expect(refused.stderr).toContain('Fix: chmod -R u+w');
expect(refused.stderr).toContain('ATTEMPTS to send off-machine');
// Queue intact (receipt is written BEFORE the commit consumes it).
const queue = fs.readFileSync(path.join(tmpHome, '.brain-queue.jsonl'), 'utf-8');
expect(queue).toContain('projects/p/learnings.jsonl');
// Spool intact (receipt is written BEFORE finalize consumes records).
expect(spoolText()).toContain('projects/p/learnings.jsonl');
// No local commit was created.
expect(git(['rev-list', '--count', 'HEAD']).stdout.trim()).toBe(commitsBefore);
// Nothing reached the remote.
@@ -433,19 +459,43 @@ describe('gstack-brain-uninstall', () => {
// --discover-new: cursor-based change detection
// ---------------------------------------------------------------
describe('gstack-brain-sync --discover-new', () => {
test('enqueues new allowlisted files; idempotent on re-run', () => {
test('enqueues new allowlisted files as spool records; idempotent on re-run', () => {
run(['gstack-artifacts-init', '--remote', bareRemote]);
run(['gstack-config', 'set', 'artifacts_sync_mode', 'full']);
fs.mkdirSync(path.join(tmpHome, 'retros'), { recursive: true });
fs.writeFileSync(path.join(tmpHome, 'retros/week-1.md'), '# retro\n');
run(['gstack-brain-sync', '--discover-new']);
let queue = fs.readFileSync(path.join(tmpHome, '.brain-queue.jsonl'), 'utf-8');
expect(queue).toContain('retros/week-1.md');
// Clear queue, run again — idempotent (no new entries).
fs.writeFileSync(path.join(tmpHome, '.brain-queue.jsonl'), '');
expect(spoolText()).toContain('retros/week-1.md');
// Clear the spool, run again — idempotent (no new records).
for (const f of spoolFiles()) fs.unlinkSync(path.join(spoolDir(), f));
run(['gstack-brain-sync', '--discover-new']);
queue = fs.readFileSync(path.join(tmpHome, '.brain-queue.jsonl'), 'utf-8');
expect(queue.trim()).toBe('');
expect(spoolFiles().length).toBe(0);
});
});
// ---------------------------------------------------------------
// Enqueue tmp janitor: a writer killed between its tmp write and the
// atomic rename orphans a .tmp-* file forever (it never becomes a
// record, nothing else touches it). The drain reaps ones older than
// 1 hour, inside its lock; fresh ones (in-flight enqueues) survive.
// ---------------------------------------------------------------
describe('enqueue tmp janitor', () => {
test('an orphaned .tmp-* older than 1h is reaped on --once; a fresh one survives', () => {
run(['gstack-artifacts-init', '--remote', bareRemote]);
run(['gstack-config', 'set', 'artifacts_sync_mode', 'full']);
fs.mkdirSync(spoolDir(), { recursive: true });
const oldTmp = path.join(spoolDir(), '.tmp-99999-x1');
fs.writeFileSync(oldTmp, '{"file":"projects/p/learnings.jsonl"}\n');
const past = new Date(Date.now() - 2 * 3600 * 1000);
fs.utimesSync(oldTmp, past, past);
const freshTmp = path.join(spoolDir(), '.tmp-99999-x2');
fs.writeFileSync(freshTmp, '{"file":"projects/p/learnings.jsonl"}\n');
expect(run(['gstack-brain-sync', '--once']).status).toBe(0);
expect(fs.existsSync(oldTmp)).toBe(false); // orphan reaped
expect(fs.existsSync(freshTmp)).toBe(true); // in-flight write untouched
});
});
@@ -458,7 +508,6 @@ describe('#2549 queue integrity', () => {
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"', () => {
@@ -470,9 +519,9 @@ describe('#2549 queue integrity', () => {
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
// "no allowlisted changes in queue". The record must survive, and the
// status must attribute the hold honestly.
expect(queueText()).toContain('projects/p/timeline.jsonl');
expect(spoolText()).toContain('projects/p/timeline.jsonl');
const s = statusJson();
expect(s.status).toBe('idle');
expect(s.message).toContain('privacy-held retained');
@@ -484,13 +533,13 @@ describe('#2549 queue integrity', () => {
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');
seedSpool('{"file":"projects/p/scratch.txt"}');
// Missing: allowlisted name that does not exist on disk.
fs.appendFileSync(path.join(tmpHome, '.brain-queue.jsonl'), '{"file":"projects/p/learnings.jsonl"}\n');
seedSpool('{"file":"projects/p/learnings.jsonl"}');
const r = run(['gstack-brain-sync', '--once']);
expect(r.status).toBe(0);
expect(queueText()).not.toContain('scratch.txt');
expect(queueText()).not.toContain('learnings.jsonl');
expect(spoolText()).not.toContain('scratch.txt');
expect(spoolText()).not.toContain('learnings.jsonl');
const s = statusJson();
expect(s.message).toContain('1 unmatched dropped');
expect(s.message).toContain('1 missing dropped');
@@ -504,17 +553,25 @@ describe('#2549 queue integrity', () => {
expect(detail.dropped.missing).toContain('projects/p/learnings.jsonl');
});
test('an unparseable queue line is preserved, never destroyed', () => {
test('an unparseable legacy queue line migrates as-is and is quarantined, never destroyed', () => {
// The line lands in the legacy single-file queue (pre-spool writer);
// migration converts it verbatim to a spool record, and the drain moves
// what it cannot parse into quarantine (never deletes it, and never
// leaves it re-warning at every boundary).
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');
const qDir = path.join(spoolDir(), 'quarantine');
expect(fs.existsSync(qDir)).toBe(true);
const qFiles = fs.readdirSync(qDir);
expect(qFiles.length).toBe(1);
expect(fs.readFileSync(path.join(qDir, qFiles[0]), 'utf-8')).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.
test('finalize: a synced record leaves the spool while a held sibling survives the same drain', () => {
// Proves finalize is a per-record delete, not a truncation: two records
// 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');
@@ -523,8 +580,8 @@ describe('#2549 queue integrity', () => {
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
expect(spoolText()).not.toContain('learnings.jsonl'); // synced, removed
expect(spoolText()).toContain('timeline.jsonl'); // held, retained
const log = spawnSync('git', ['--git-dir=' + bareRemote, 'log', '--oneline'], { encoding: 'utf-8' });
expect(log.stdout).toMatch(/sync: 1 file/);
});
@@ -550,8 +607,8 @@ describe('#2549 queue integrity', () => {
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');
// Drained record left the spool — it lives in the local commit now.
expect(spoolText()).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);
@@ -680,3 +737,226 @@ describe('#2549 queue integrity', () => {
expect(git(['rev-list', '--count', 'origin/main..HEAD']).stdout.trim()).toBe('0');
});
});
// ---------------------------------------------------------------
// C12 spool queue: per-record files kill the enqueue/drain race.
// One FILE per record under .brain-queue.d/ — writer and drainer never
// share an inode, so the lockless append-vs-rewrite race is structurally
// gone. Crash semantics are at-least-once (unfinalized records re-drain).
// ---------------------------------------------------------------
describe('C12 spool queue', () => {
function initWithMode(mode: string) {
run(['gstack-artifacts-init', '--remote', bareRemote]);
run(['gstack-config', 'set', 'artifacts_sync_mode', mode]);
}
const remoteLog = () =>
spawnSync('git', ['--git-dir=' + bareRemote, 'log', '--oneline'], { encoding: 'utf-8' }).stdout;
test('two rapid enqueues of different paths create two spool files; one drain syncs both', () => {
initWithMode('full');
fs.mkdirSync(path.join(tmpHome, 'projects', 'p'), { recursive: true });
fs.mkdirSync(path.join(tmpHome, 'retros'), { recursive: true });
fs.writeFileSync(path.join(tmpHome, 'projects/p/learnings.jsonl'), '{"skill":"x","ts":"2026-01-01T00:00:00Z"}\n');
fs.writeFileSync(path.join(tmpHome, 'retros/week-1.md'), '# retro\n');
run(['gstack-brain-enqueue', 'projects/p/learnings.jsonl']);
run(['gstack-brain-enqueue', 'retros/week-1.md']);
expect(spoolFiles().length).toBe(2);
const r = run(['gstack-brain-sync', '--once']);
expect(r.status).toBe(0);
expect(spoolFiles().length).toBe(0);
expect(remoteLog()).toMatch(/sync: 2 file/);
});
test('a record created after a drain survives untouched and drains on the NEXT --once', () => {
// Structural form of the concurrent-append test: finalize deletes only
// snapshot-manifest files, so a record the drain never listed cannot be
// touched — whether it lands mid-drain or after.
initWithMode('full');
fs.mkdirSync(path.join(tmpHome, 'projects', 'p'), { recursive: true });
fs.mkdirSync(path.join(tmpHome, 'retros'), { 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);
expect(spoolFiles().length).toBe(0);
// New record arrives (a writer that raced the previous drain).
fs.writeFileSync(path.join(tmpHome, 'retros/week-1.md'), '# retro\n');
run(['gstack-brain-enqueue', 'retros/week-1.md']);
const [pending] = spoolFiles();
expect(pending).toBeTruthy();
const pendingContent = fs.readFileSync(path.join(spoolDir(), pending), 'utf-8');
expect(pendingContent).toContain('retros/week-1.md');
// Untouched by the completed drain; the NEXT drain delivers it.
expect(run(['gstack-brain-sync', '--once']).status).toBe(0);
expect(spoolFiles().length).toBe(0);
expect(remoteLog()).toMatch(/sync: 1 file/);
});
test('at-least-once: a drain that fails before finalize leaves every spool file for the next run', () => {
if (process.platform === 'win32' || process.getuid?.() === 0) return; // chmod advisory there
initWithMode('full');
fs.mkdirSync(path.join(tmpHome, 'projects', 'p'), { recursive: true });
fs.mkdirSync(path.join(tmpHome, 'retros'), { recursive: true });
fs.writeFileSync(path.join(tmpHome, 'projects/p/learnings.jsonl'), '{"skill":"a","ts":"2026-01-01T00:00:00Z"}\n');
fs.writeFileSync(path.join(tmpHome, 'retros/week-1.md'), '# retro\n');
run(['gstack-brain-enqueue', 'projects/p/learnings.jsonl']);
run(['gstack-brain-enqueue', 'retros/week-1.md']);
const seeded = spoolFiles();
expect(seeded.length).toBe(2);
// Break the egress-receipt ledger: the drain fails AFTER staging but
// BEFORE any commit or finalize — simulating a crash mid-drain.
fs.mkdirSync(path.join(tmpHome, 'security'), { recursive: true });
fs.chmodSync(path.join(tmpHome, 'security'), 0o500);
try {
const refused = run(['gstack-brain-sync', '--once']);
expect(refused.status).toBe(1);
// The exact same spool files are still present — nothing consumed.
expect(spoolFiles()).toEqual(seeded);
} finally {
fs.chmodSync(path.join(tmpHome, 'security'), 0o700);
}
// Next run re-drains the surviving records.
expect(run(['gstack-brain-sync', '--once']).status).toBe(0);
expect(spoolFiles().length).toBe(0);
expect(remoteLog()).toMatch(/sync: 2 file/);
});
test('legacy migration: .brain-queue.jsonl lines convert to spool records, nothing lost', () => {
// Pre-spool writers appended to the single-file queue. Three lines: two
// stageable artifacts, one behavioral (mode-held under artifacts-only).
initWithMode('artifacts-only');
fs.mkdirSync(path.join(tmpHome, 'projects', 'p'), { recursive: true });
fs.mkdirSync(path.join(tmpHome, 'retros'), { recursive: true });
fs.writeFileSync(path.join(tmpHome, 'projects/p/learnings.jsonl'), '{"skill":"x","ts":"2026-01-01T00:00:00Z"}\n');
fs.writeFileSync(path.join(tmpHome, 'retros/week-1.md'), '# retro\n');
fs.writeFileSync(path.join(tmpHome, 'projects/p/timeline.jsonl'), '{"skill":"x","event":"started"}\n');
fs.writeFileSync(path.join(tmpHome, '.brain-queue.jsonl'),
'{"file":"projects/p/learnings.jsonl","ts":"2026-01-01T00:00:00Z"}\n' +
'{"file":"retros/week-1.md","ts":"2026-01-01T00:00:01Z"}\n' +
'{"file":"projects/p/timeline.jsonl","ts":"2026-01-01T00:00:02Z"}\n');
const r = run(['gstack-brain-sync', '--once']);
expect(r.status).toBe(0);
// Legacy file consumed; no .migrating remnant.
expect(fs.existsSync(path.join(tmpHome, '.brain-queue.jsonl'))).toBe(false);
expect(fs.existsSync(path.join(tmpHome, '.brain-queue.jsonl.migrating'))).toBe(false);
// Both artifacts synced; the behavioral record survives as a spool file.
expect(remoteLog()).toMatch(/sync: 2 file/);
expect(spoolText()).toContain('projects/p/timeline.jsonl');
expect(spoolText()).not.toContain('learnings.jsonl');
});
test('an unparseable spool record is quarantined with a warning; the drain continues', () => {
initWithMode('full');
fs.mkdirSync(path.join(tmpHome, 'projects', 'p'), { recursive: true });
fs.writeFileSync(path.join(tmpHome, 'projects/p/learnings.jsonl'), '{"skill":"x","ts":"2026-01-01T00:00:00Z"}\n');
run(['gstack-brain-enqueue', 'projects/p/learnings.jsonl']);
const badFile = seedSpool('this is not json');
const r = run(['gstack-brain-sync', '--once']);
expect(r.status).toBe(0);
expect(r.stderr).toContain('unparseable');
// The good sibling synced; the unreadable record was never destroyed —
// it moved to quarantine so it stops re-warning at every boundary.
expect(remoteLog()).toMatch(/sync: 1 file/);
expect(spoolFiles()).toEqual([]);
const qPath = path.join(spoolDir(), 'quarantine', badFile);
expect(fs.existsSync(qPath)).toBe(true);
expect(fs.readFileSync(qPath, 'utf-8')).toContain('this is not json');
});
test('--status queue_depth counts spool records plus unmigrated legacy lines', () => {
initWithMode('full');
seedSpool('{"file":"projects/p/a.jsonl","ts":"t"}');
seedSpool('{"file":"projects/p/b.jsonl","ts":"t"}');
fs.writeFileSync(path.join(tmpHome, '.brain-queue.jsonl'), '{"file":"projects/p/c.jsonl","ts":"t"}\n');
const r = run(['gstack-brain-sync', '--status']);
expect(r.status).toBe(0);
const supplemental = JSON.parse(r.stdout.trim().split('\n').pop()!);
expect(supplemental.queue_depth).toBe(3);
});
test('G1: a malformed pulled privacy map (["bad"]) holds the queue — warns, deletes NOTHING, next run re-drains', () => {
// Remotely triggerable kill vector: the privacy map arrives via the
// artifacts-repo pull. A non-dict entry used to raise mid-classification
// AFTER the snapshot manifest was written, and the old finalize polarity
// ("delete unless retained") then unlinked EVERY snapshotted record.
initWithMode('full');
fs.mkdirSync(path.join(tmpHome, 'projects', 'p'), { recursive: true });
fs.writeFileSync(path.join(tmpHome, 'projects/p/learnings.jsonl'), '{"skill":"x","ts":"2026-01-01T00:00:00Z"}\n');
run(['gstack-brain-enqueue', 'projects/p/learnings.jsonl']);
const seeded = spoolFiles();
expect(seeded.length).toBe(1);
fs.writeFileSync(path.join(tmpHome, '.brain-privacy-map.json'), '["bad"]');
const r = run(['gstack-brain-sync', '--once']);
expect(r.status).toBe(0);
expect(r.stderr).toContain('privacy map');
// Zero records deleted; nothing pushed.
expect(spoolFiles()).toEqual(seeded);
expect(remoteLog()).not.toMatch(/sync:/);
// Fix the map: the surviving queue re-drains and syncs.
fs.writeFileSync(path.join(tmpHome, '.brain-privacy-map.json'), '[]');
expect(run(['gstack-brain-sync', '--once']).status).toBe(0);
expect(spoolFiles().length).toBe(0);
expect(remoteLog()).toMatch(/sync: 1 file/);
});
test('G1: a classifier that dies AFTER the snapshot write consumes nothing (call-site exit check + explicit-delete finalize)', () => {
// A dict entry with a non-string pattern passes the shape filter but
// raises inside fnmatch DURING classification — the post-manifest crash
// window (same shape as ENOSPC/OOM mid-run). The call site must see the
// nonzero exit, warn, skip finalize, and leave everything queued.
initWithMode('full');
fs.mkdirSync(path.join(tmpHome, 'projects', 'p'), { recursive: true });
fs.writeFileSync(path.join(tmpHome, 'projects/p/learnings.jsonl'), '{"skill":"x","ts":"2026-01-01T00:00:00Z"}\n');
run(['gstack-brain-enqueue', 'projects/p/learnings.jsonl']);
const seeded = spoolFiles();
expect(seeded.length).toBe(1);
fs.writeFileSync(path.join(tmpHome, '.brain-privacy-map.json'), '[{"pattern": 123}]');
const r = run(['gstack-brain-sync', '--once']);
expect(r.status).toBe(0);
expect(r.stderr).toContain('classification failed');
expect(spoolFiles()).toEqual(seeded); // zero records deleted
expect(remoteLog()).not.toMatch(/sync:/);
const status = JSON.parse(fs.readFileSync(path.join(tmpHome, '.brain-sync-status.json'), 'utf-8'));
expect(status.status).toBe('error');
expect(status.message).toContain('queue preserved');
// Fix the map: the surviving queue re-drains and syncs.
fs.writeFileSync(path.join(tmpHome, '.brain-privacy-map.json'), '[]');
expect(run(['gstack-brain-sync', '--once']).status).toBe(0);
expect(spoolFiles().length).toBe(0);
expect(remoteLog()).toMatch(/sync: 1 file/);
});
test('G1: finalize is explicit-delete-only and the fast path is .migrating-aware (static pins)', () => {
const src = fs.readFileSync(path.join(BIN, 'gstack-brain-sync'), 'utf-8');
// The compute call site checks the python exit status before finalizing.
expect(src).toMatch(/if ! compute_paths_to_stage /);
// finalize_queue takes the staged-paths file and deletes only staged dropped.
expect(src).toContain('deletable = staged | dropped');
expect(src).toContain('if p not in deletable:');
// The empty fast path also treats a leftover .migrating file as non-idle.
expect(src).toMatch(/spool_has_records && \[ ! -s "\$QUEUE" \] && \[ ! -s "\$QUEUE\.migrating" \]/);
});
test('--drop-queue keeps the --yes gate and counts spool + legacy entries', () => {
initWithMode('full');
seedSpool('{"file":"projects/p/a.jsonl","ts":"t"}');
seedSpool('{"file":"projects/p/b.jsonl","ts":"t"}');
fs.writeFileSync(path.join(tmpHome, '.brain-queue.jsonl'), '{"file":"projects/p/c.jsonl","ts":"t"}\n');
const refused = run(['gstack-brain-sync', '--drop-queue']);
expect(refused.status).toBe(1);
expect(refused.stderr).toContain('--yes');
expect(spoolFiles().length).toBe(2);
const dropped = run(['gstack-brain-sync', '--drop-queue', '--yes']);
expect(dropped.status).toBe(0);
expect(dropped.stdout).toContain('dropped 3 queue entries');
expect(spoolFiles().length).toBe(0);
expect(fs.readFileSync(path.join(tmpHome, '.brain-queue.jsonl'), 'utf-8')).toBe('');
const again = run(['gstack-brain-sync', '--drop-queue', '--yes']);
expect(again.stdout).toContain('queue already empty');
});
});
+13 -3
View File
@@ -515,7 +515,7 @@ _BRAIN_SYNC_MODE=$("$_BRAIN_CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || e
_GBRAIN_MCP_MODE="none"
_GBRAIN_MCP_ENTRY=""
if command -v jq >/dev/null 2>&1 && [ -f "$HOME/.claude.json" ]; then
_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_ENTRY=$(jq -c --arg cwd "$PWD" '((.projects // {}) | to_entries | map(select((.key as $k | $cwd == $k or ($cwd | startswith($k + "/")) or ($cwd | startswith($k + "\\"))) and ((try .value.mcpServers.gbrain catch null) != null))) | sort_by(.key | length) | last | .value.mcpServers.gbrain) // .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" ;;
@@ -555,7 +555,11 @@ if [ "$_GBRAIN_MCP_MODE" = "remote-http" ]; then
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
[ -f "$_GSTACK_HOME/.brain-queue.jsonl" ] && _BRAIN_QUEUE_DEPTH=$(wc -l < "$_GSTACK_HOME/.brain-queue.jsonl" | tr -d ' ')
# Spool-dir queue (one file per record); legacy .brain-queue.jsonl lines are
# counted too until the drain migrates them.
[ -d "$_GSTACK_HOME/.brain-queue.d" ] && _BRAIN_QUEUE_DEPTH=$(find "$_GSTACK_HOME/.brain-queue.d" -maxdepth 1 -name '*.json' 2>/dev/null | wc -l | tr -d ' ')
[ -f "$_GSTACK_HOME/.brain-queue.jsonl" ] && _BRAIN_QUEUE_DEPTH=$(( _BRAIN_QUEUE_DEPTH + $(wc -l < "$_GSTACK_HOME/.brain-queue.jsonl" | tr -d ' ') ))
[ -f "$_GSTACK_HOME/.brain-queue.jsonl.migrating" ] && _BRAIN_QUEUE_DEPTH=$(( _BRAIN_QUEUE_DEPTH + $(wc -l < "$_GSTACK_HOME/.brain-queue.jsonl.migrating" | tr -d ' ') ))
_BRAIN_LAST_PUSH="never"
[ -f "$_GSTACK_HOME/.brain-last-push" ] && _BRAIN_LAST_PUSH=$(cat "$_GSTACK_HOME/.brain-last-push" 2>/dev/null || echo never)
echo "ARTIFACTS_SYNC: mode=$_BRAIN_SYNC_MODE | last_push=$_BRAIN_LAST_PUSH | queue=$_BRAIN_QUEUE_DEPTH"
@@ -773,7 +777,13 @@ Escalate after 3 failed attempts, uncertain security-sensitive changes, or scope
## Operational Self-Improvement
Before completing, if you discovered a durable project quirk or command fix that would save 5+ minutes next time, log it:
Before completing, review the session for durable learnings and log each one —
this step ALWAYS runs, it is not conditional on something feeling noteworthy
(#2402: 43 of 44 learnings came from explicit /learn because "if you
discovered" read as optional). A durable learning is a project quirk, command
fix, pitfall, or pattern that would save 5+ minutes in a future session. If
the review genuinely surfaces none, state "No durable learnings this session"
in your completion summary — an explicit empty result, not a skipped step.
```bash
~/.claude/skills/gstack/bin/gstack-learnings-log '{"skill":"SKILL_NAME","type":"operational","key":"SHORT_KEY","insight":"DESCRIPTION","confidence":N,"source":"observed"}'
+15 -5
View File
@@ -501,7 +501,7 @@ _BRAIN_SYNC_MODE=$("$_BRAIN_CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || e
_GBRAIN_MCP_MODE="none"
_GBRAIN_MCP_ENTRY=""
if command -v jq >/dev/null 2>&1 && [ -f "$HOME/.claude.json" ]; then
_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_ENTRY=$(jq -c --arg cwd "$PWD" '((.projects // {}) | to_entries | map(select((.key as $k | $cwd == $k or ($cwd | startswith($k + "/")) or ($cwd | startswith($k + "\\"))) and ((try .value.mcpServers.gbrain catch null) != null))) | sort_by(.key | length) | last | .value.mcpServers.gbrain) // .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" ;;
@@ -541,7 +541,11 @@ if [ "$_GBRAIN_MCP_MODE" = "remote-http" ]; then
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
[ -f "$_GSTACK_HOME/.brain-queue.jsonl" ] && _BRAIN_QUEUE_DEPTH=$(wc -l < "$_GSTACK_HOME/.brain-queue.jsonl" | tr -d ' ')
# Spool-dir queue (one file per record); legacy .brain-queue.jsonl lines are
# counted too until the drain migrates them.
[ -d "$_GSTACK_HOME/.brain-queue.d" ] && _BRAIN_QUEUE_DEPTH=$(find "$_GSTACK_HOME/.brain-queue.d" -maxdepth 1 -name '*.json' 2>/dev/null | wc -l | tr -d ' ')
[ -f "$_GSTACK_HOME/.brain-queue.jsonl" ] && _BRAIN_QUEUE_DEPTH=$(( _BRAIN_QUEUE_DEPTH + $(wc -l < "$_GSTACK_HOME/.brain-queue.jsonl" | tr -d ' ') ))
[ -f "$_GSTACK_HOME/.brain-queue.jsonl.migrating" ] && _BRAIN_QUEUE_DEPTH=$(( _BRAIN_QUEUE_DEPTH + $(wc -l < "$_GSTACK_HOME/.brain-queue.jsonl.migrating" | tr -d ' ') ))
_BRAIN_LAST_PUSH="never"
[ -f "$_GSTACK_HOME/.brain-last-push" ] && _BRAIN_LAST_PUSH=$(cat "$_GSTACK_HOME/.brain-last-push" 2>/dev/null || echo never)
echo "ARTIFACTS_SYNC: mode=$_BRAIN_SYNC_MODE | last_push=$_BRAIN_LAST_PUSH | queue=$_BRAIN_QUEUE_DEPTH"
@@ -781,7 +785,13 @@ Escalate after 3 failed attempts, uncertain security-sensitive changes, or scope
## Operational Self-Improvement
Before completing, if you discovered a durable project quirk or command fix that would save 5+ minutes next time, log it:
Before completing, review the session for durable learnings and log each one —
this step ALWAYS runs, it is not conditional on something feeling noteworthy
(#2402: 43 of 44 learnings came from explicit /learn because "if you
discovered" read as optional). A durable learning is a project quirk, command
fix, pitfall, or pattern that would save 5+ minutes in a future session. If
the review genuinely surfaces none, state "No durable learnings this session"
in your completion summary — an explicit empty result, not a skipped step.
```bash
$GSTACK_BIN/gstack-learnings-log '{"skill":"SKILL_NAME","type":"operational","key":"SHORT_KEY","insight":"DESCRIPTION","confidence":N,"source":"observed"}'
@@ -2024,7 +2034,7 @@ Before reviewing code quality, check: **did they build what was requested — no
Review the diff for structural issues that tests don't catch.
1. Read `.agents/skills/gstack/review/checklist.md`. If the file cannot be read, **STOP** and report the error.
1. Read `$GSTACK_ROOT/review/checklist.md`. If the file cannot be read, **STOP** and report the error.
2. Run `git diff origin/<base>` to get the full diff (scoped to feature changes against the freshly-fetched base branch).
@@ -2206,7 +2216,7 @@ Save the review output — it goes into the PR body in Step 19.
**Subagent prompt:**
> You are classifying Greptile review comments for a /ship workflow. Read `.agents/skills/gstack/review/greptile-triage.md` and follow the fetch, filter, classify, and **escalation detection** steps. Do NOT fix code, do NOT reply to comments, do NOT commit — report only.
> You are classifying Greptile review comments for a /ship workflow. Read `$GSTACK_ROOT/review/greptile-triage.md` and follow the fetch, filter, classify, and **escalation detection** steps. Do NOT fix code, do NOT reply to comments, do NOT commit — report only.
>
> For each comment, assign: `classification` (`valid_actionable`, `already_fixed`, `false_positive`, `suppressed`), `escalation_tier` (1 or 2), the file:line or [top-level] tag, body summary, and permalink URL.
>
+15 -5
View File
@@ -503,7 +503,7 @@ _BRAIN_SYNC_MODE=$("$_BRAIN_CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || e
_GBRAIN_MCP_MODE="none"
_GBRAIN_MCP_ENTRY=""
if command -v jq >/dev/null 2>&1 && [ -f "$HOME/.claude.json" ]; then
_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_ENTRY=$(jq -c --arg cwd "$PWD" '((.projects // {}) | to_entries | map(select((.key as $k | $cwd == $k or ($cwd | startswith($k + "/")) or ($cwd | startswith($k + "\\"))) and ((try .value.mcpServers.gbrain catch null) != null))) | sort_by(.key | length) | last | .value.mcpServers.gbrain) // .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" ;;
@@ -543,7 +543,11 @@ if [ "$_GBRAIN_MCP_MODE" = "remote-http" ]; then
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
[ -f "$_GSTACK_HOME/.brain-queue.jsonl" ] && _BRAIN_QUEUE_DEPTH=$(wc -l < "$_GSTACK_HOME/.brain-queue.jsonl" | tr -d ' ')
# Spool-dir queue (one file per record); legacy .brain-queue.jsonl lines are
# counted too until the drain migrates them.
[ -d "$_GSTACK_HOME/.brain-queue.d" ] && _BRAIN_QUEUE_DEPTH=$(find "$_GSTACK_HOME/.brain-queue.d" -maxdepth 1 -name '*.json' 2>/dev/null | wc -l | tr -d ' ')
[ -f "$_GSTACK_HOME/.brain-queue.jsonl" ] && _BRAIN_QUEUE_DEPTH=$(( _BRAIN_QUEUE_DEPTH + $(wc -l < "$_GSTACK_HOME/.brain-queue.jsonl" | tr -d ' ') ))
[ -f "$_GSTACK_HOME/.brain-queue.jsonl.migrating" ] && _BRAIN_QUEUE_DEPTH=$(( _BRAIN_QUEUE_DEPTH + $(wc -l < "$_GSTACK_HOME/.brain-queue.jsonl.migrating" | tr -d ' ') ))
_BRAIN_LAST_PUSH="never"
[ -f "$_GSTACK_HOME/.brain-last-push" ] && _BRAIN_LAST_PUSH=$(cat "$_GSTACK_HOME/.brain-last-push" 2>/dev/null || echo never)
echo "ARTIFACTS_SYNC: mode=$_BRAIN_SYNC_MODE | last_push=$_BRAIN_LAST_PUSH | queue=$_BRAIN_QUEUE_DEPTH"
@@ -761,7 +765,13 @@ Escalate after 3 failed attempts, uncertain security-sensitive changes, or scope
## Operational Self-Improvement
Before completing, if you discovered a durable project quirk or command fix that would save 5+ minutes next time, log it:
Before completing, review the session for durable learnings and log each one —
this step ALWAYS runs, it is not conditional on something feeling noteworthy
(#2402: 43 of 44 learnings came from explicit /learn because "if you
discovered" read as optional). A durable learning is a project quirk, command
fix, pitfall, or pattern that would save 5+ minutes in a future session. If
the review genuinely surfaces none, state "No durable learnings this session"
in your completion summary — an explicit empty result, not a skipped step.
```bash
$GSTACK_BIN/gstack-learnings-log '{"skill":"SKILL_NAME","type":"operational","key":"SHORT_KEY","insight":"DESCRIPTION","confidence":N,"source":"observed"}'
@@ -2031,7 +2041,7 @@ Before reviewing code quality, check: **did they build what was requested — no
Review the diff for structural issues that tests don't catch.
1. Read `.factory/skills/gstack/review/checklist.md`. If the file cannot be read, **STOP** and report the error.
1. Read `$GSTACK_ROOT/review/checklist.md`. If the file cannot be read, **STOP** and report the error.
2. Run `git diff origin/<base>` to get the full diff (scoped to feature changes against the freshly-fetched base branch).
@@ -2438,7 +2448,7 @@ Save the review output — it goes into the PR body in Step 19.
**Subagent prompt:**
> You are classifying Greptile review comments for a /ship workflow. Read `.factory/skills/gstack/review/greptile-triage.md` and follow the fetch, filter, classify, and **escalation detection** steps. Do NOT fix code, do NOT reply to comments, do NOT commit — report only.
> You are classifying Greptile review comments for a /ship workflow. Read `$GSTACK_ROOT/review/greptile-triage.md` and follow the fetch, filter, classify, and **escalation detection** steps. Do NOT fix code, do NOT reply to comments, do NOT commit — report only.
>
> For each comment, assign: `classification` (`valid_actionable`, `already_fixed`, `false_positive`, `suppressed`), `escalation_tier` (1 or 2), the file:line or [top-level] tag, body summary, and permalink URL.
>
+116 -2
View File
@@ -594,13 +594,16 @@ describe("lib/gbrain-local-status — bearer-token thin-client (#2520)", () => {
expect(localEngineStatus({ noCache: true })).toBe("thin-client");
});
it("returns 'thin-client' when config.json is absent and the registration is PROJECT-scoped (#2499)", () => {
it("returns 'thin-client' when config.json is absent and the registration is PROJECT-scoped for THIS cwd (#2499)", () => {
// The project key must be the running process's cwd (or an ancestor):
// per-project scoping (C15) means only registrations visible to this
// cwd count.
env = makeEnv({
withGbrain: true,
gbrainBehavior: "ok",
withConfig: false,
claudeJson: {
projects: { "/some/repo": { mcpServers: { "gbrain-remote": REMOTE_GBRAIN } } },
projects: { [process.cwd()]: { mcpServers: { "gbrain-remote": REMOTE_GBRAIN } } },
},
});
restoreEnv = applyEnv(env);
@@ -659,6 +662,117 @@ describe("lib/gbrain-local-status — bearer-token thin-client (#2520)", () => {
expect(localEngineStatus({ noCache: true })).toBe("missing-config");
});
// ── C15: project scan is scoped to the cwd's nearest-ancestor project ──
// Before the fix, hasRemoteOnlyGbrainMcp scanned EVERY project's
// mcpServers, so one project's remote registration reclassified broken
// local engines as thin-client machine-wide.
it("C15: an OTHER project's remote entry no longer flips thin-client for this cwd (no config)", () => {
env = makeEnv({
withGbrain: true,
gbrainBehavior: "ok",
withConfig: false,
claudeJson: {
projects: { "/some/other/repo": { mcpServers: { gbrain: REMOTE_GBRAIN } } },
},
});
restoreEnv = applyEnv(env);
expect(localEngineStatus({ noCache: true })).toBe("missing-config");
});
it("C15: an OTHER project's remote entry no longer reclassifies a broken local engine", () => {
env = makeEnv({
withGbrain: true,
gbrainBehavior: "engine-locked",
withConfig: true,
claudeJson: {
projects: { "/some/other/repo": { mcpServers: { gbrain: REMOTE_GBRAIN } } },
},
});
restoreEnv = applyEnv(env);
expect(localEngineStatus({ noCache: true })).toBe("engine-locked");
});
it("C15: path boundary — a sibling-prefix project key is NOT this cwd's project", () => {
// /path/to/repo2 must never match a scan from /path/to/repo (and vice
// versa) — same boundary rule as the jq resolver and brain-cache.
env = makeEnv({
withGbrain: true,
gbrainBehavior: "ok",
withConfig: false,
claudeJson: {
projects: { [`${process.cwd()}-sibling`]: { mcpServers: { gbrain: REMOTE_GBRAIN } } },
},
});
restoreEnv = applyEnv(env);
expect(localEngineStatus({ noCache: true })).toBe("missing-config");
});
it("C15: an ANCESTOR project key of this cwd still counts (nearest-ancestor matching)", () => {
env = makeEnv({
withGbrain: true,
gbrainBehavior: "ok",
withConfig: false,
claudeJson: {
projects: { [dirname(process.cwd())]: { mcpServers: { gbrain: REMOTE_GBRAIN } } },
},
});
restoreEnv = applyEnv(env);
expect(localEngineStatus({ noCache: true })).toBe("thin-client");
});
it("C15: a nearer project WITHOUT gbrain does not shadow an ancestor's registration (jq parity)", () => {
env = makeEnv({
withGbrain: true,
gbrainBehavior: "ok",
withConfig: false,
claudeJson: {
projects: {
[dirname(process.cwd())]: { mcpServers: { gbrain: REMOTE_GBRAIN } },
[process.cwd()]: { mcpServers: { "other-server": { type: "http", url: "https://x.example/mcp" } } },
},
},
});
restoreEnv = applyEnv(env);
expect(localEngineStatus({ noCache: true })).toBe("thin-client");
});
// ── C15: adopted precedence — project-local beats user scope per name ──
// Claude Code's own conflict resolution, verified empirically against
// claude 2.1.233 with a hermetic fake $HOME (`claude mcp get gbrain`
// reports "Scope: Local config" when both scopes define the name).
it("C15 precedence: THIS project's remote gbrain shadows a user-scope local-stdio gbrain → thin-client", () => {
// Union semantics would see the user-scope stdio entry and keep
// engine-locked; the adopted precedence says this project's queries go
// remote, so thin-client is the truthful classification here.
env = makeEnv({
withGbrain: true,
gbrainBehavior: "engine-locked",
withConfig: true,
claudeJson: {
mcpServers: { gbrain: LOCAL_GBRAIN },
projects: { [process.cwd()]: { mcpServers: { gbrain: REMOTE_GBRAIN } } },
},
});
restoreEnv = applyEnv(env);
expect(localEngineStatus({ noCache: true })).toBe("thin-client");
});
it("C15 precedence: THIS project's local-stdio gbrain shadows a user-scope remote gbrain → local statuses keep their meaning", () => {
env = makeEnv({
withGbrain: true,
gbrainBehavior: "engine-locked",
withConfig: true,
claudeJson: {
mcpServers: { gbrain: REMOTE_GBRAIN },
projects: { [process.cwd()]: { mcpServers: { gbrain: LOCAL_GBRAIN } } },
},
});
restoreEnv = applyEnv(env);
expect(localEngineStatus({ noCache: true })).toBe("engine-locked");
});
it("--is-ok exits 0 on a bearer thin-client fixture (end-to-end gate)", () => {
env = makeEnv({
withGbrain: true,
+210
View File
@@ -0,0 +1,210 @@
/**
* lib/gbrain-repo-policy-client batch trust-tier lookup (#2392).
*
* Covers the `get --batch` verb of bin/gstack-gbrain-repo-policy (bash level:
* multiple urls in, per-line verdicts out, input order preserved) and the
* TypeScript client `repoPolicyTierBatch` (ONE spawn, dedup, fast paths, and
* the whole-batch `unreadable` classification on a corrupt store).
*
* Each test uses a temp GSTACK_HOME so nothing leaks into the user's real
* ~/.gstack. The client is exercised against the REAL bash script the
* script owns URL normalization, so stub stores are seeded through its own
* `set` verb.
*/
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
import * as fs from "fs";
import * as path from "path";
import * as os from "os";
import { spawnSync } from "child_process";
import { repoPolicyTierBatch } from "../lib/gbrain-repo-policy-client";
import { canonicalizeRemote } from "../lib/gstack-memory-helpers";
const ROOT = path.resolve(import.meta.dir, "..");
const BIN = path.join(ROOT, "bin", "gstack-gbrain-repo-policy");
let tmpHome: string;
function env(): NodeJS.ProcessEnv {
return { ...process.env, GSTACK_HOME: tmpHome };
}
function run(args: string[], input?: string) {
const res = spawnSync(BIN, args, { env: env(), encoding: "utf-8", input });
return {
stdout: res.stdout || "",
stderr: res.stderr || "",
status: res.status ?? -1,
};
}
function policyFile(): string {
return path.join(tmpHome, "gbrain-repo-policy.json");
}
beforeEach(() => {
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "gbrain-policy-client-"));
});
afterEach(() => {
fs.rmSync(tmpHome, { recursive: true, force: true });
});
describe("bin/gstack-gbrain-repo-policy get --batch (bash level)", () => {
test("multiple urls in, per-line verdicts out, input order preserved", () => {
expect(run(["set", "https://github.com/foo/bar.git", "deny"]).status).toBe(0);
expect(run(["set", "git@github.com:baz/qux.git", "read-only"]).status).toBe(0);
expect(run(["set", "https://github.com/rw/repo", "read-write"]).status).toBe(0);
const r = run(
["get", "--batch"],
// Mixed URL forms — the script's normalize() collapses them to the
// stored keys. `nope/never` has no entry → none.
"git@github.com:foo/bar.git\nhttps://github.com/nope/never\nhttps://github.com/baz/qux\nhttps://github.com/rw/repo.git\n",
);
expect(r.status).toBe(0);
expect(r.stdout).toBe("deny\nnone\nread-only\nread-write\n");
});
test("no store on disk: every line is none, and no file is created", () => {
const r = run(["get", "--batch"], "https://github.com/a/a\nhttps://github.com/b/b\n");
expect(r.status).toBe(0);
expect(r.stdout).toBe("none\nnone\n");
expect(fs.existsSync(policyFile())).toBe(false);
});
test("corrupt store: hard error (exit 2), NOT quarantined, names recovery", () => {
fs.writeFileSync(policyFile(), "not valid json{", { mode: 0o600 });
const r = run(["get", "--batch"], "https://github.com/foo/bar\n");
expect(r.status).toBe(2);
expect(r.stderr).toContain("corrupt");
expect(r.stderr).toContain("gstack-gbrain-repo-policy list");
// Unlike interactive `get`, batch must never quarantine-and-proceed —
// that would bypass a set deny policy on an unattended ingest run.
expect(fs.readFileSync(policyFile(), "utf-8")).toBe("not valid json{");
expect(
fs.readdirSync(tmpHome).find((f) => f.includes(".corrupt-")),
).toBeUndefined();
});
test("legacy allow entries migrate to read-write on batch read", () => {
fs.writeFileSync(
policyFile(),
JSON.stringify({ "github.com/foo/bar": "allow" }),
{ mode: 0o600 },
);
const r = run(["get", "--batch"], "https://github.com/foo/bar\n");
expect(r.status).toBe(0);
expect(r.stdout).toBe("read-write\n");
});
});
describe("repoPolicyTierBatch (TypeScript client)", () => {
test("maps each input url to its verdict, dedup included", () => {
expect(run(["set", "https://github.com/foo/bar", "deny"]).status).toBe(0);
expect(run(["set", "https://github.com/baz/qux", "read-only"]).status).toBe(0);
const verdicts = repoPolicyTierBatch(
[
"github.com/foo/bar", // canonical form, as memory-ingest passes it
"github.com/baz/qux",
"github.com/nope/never",
"github.com/foo/bar", // duplicate — dedup keeps ONE map entry
],
env(),
);
expect(verdicts.size).toBe(3);
expect(verdicts.get("github.com/foo/bar")).toEqual({ tier: "deny" });
expect(verdicts.get("github.com/baz/qux")).toEqual({ tier: "read-only" });
expect(verdicts.get("github.com/nope/never")).toEqual({ tier: "none" });
});
test("no store on disk: every url is tier none with no error (fast path)", () => {
const verdicts = repoPolicyTierBatch(["github.com/a/a", "github.com/b/b"], env());
expect(verdicts.get("github.com/a/a")).toEqual({ tier: "none" });
expect(verdicts.get("github.com/b/b")).toEqual({ tier: "none" });
expect(fs.existsSync(policyFile())).toBe(false);
});
test("empty url list returns an empty map without spawning", () => {
const verdicts = repoPolicyTierBatch([], env());
expect(verdicts.size).toBe(0);
});
test("corrupt store: EVERY url maps to { tier: none, error: unreadable }", () => {
fs.writeFileSync(policyFile(), "not valid json{", { mode: 0o600 });
const verdicts = repoPolicyTierBatch(["github.com/foo/bar", "github.com/baz/qux"], env());
expect(verdicts.get("github.com/foo/bar")).toEqual({ tier: "none", error: "unreadable" });
expect(verdicts.get("github.com/baz/qux")).toEqual({ tier: "none", error: "unreadable" });
});
test("store unreadable on disk (chmod 000): whole batch classified unreadable", () => {
if (process.platform === "win32" || process.getuid?.() === 0) return; // chmod semantics differ
expect(run(["set", "https://github.com/foo/bar", "deny"]).status).toBe(0);
fs.chmodSync(policyFile(), 0o000);
try {
const verdicts = repoPolicyTierBatch(["github.com/foo/bar"], env());
expect(verdicts.get("github.com/foo/bar")).toEqual({ tier: "none", error: "unreadable" });
} finally {
fs.chmodSync(policyFile(), 0o600);
}
});
});
// ── Normalize parity: bash normalize() ↔ lib canonicalizeRemote ─────────────
//
// bin/gstack-memory-ingest.ts produces page.git_remote via canonicalizeRemote
// (lib/gstack-memory-helpers) and then looks the policy up through
// repoPolicyTierBatch — whose bash side re-normalizes with normalize(). If
// the two functions disagree on ANY URL shape, a policy the user set via the
// script silently fails to apply to ingest (a deny that doesn't deny). The
// contract pinned here: for every shape X, `set X <tier>` followed by a batch
// lookup of canonicalizeRemote(X) returns <tier>. Bash owns normalization —
// any divergence is fixed in the SCRIPT's normalize(), never by re-normalizing
// in TypeScript.
describe("normalize parity: bash normalize() ↔ canonicalizeRemote (edge URL shapes)", () => {
// One distinct repo per shape so tiers don't overwrite each other.
const CORPUS: Array<{ shape: string; tier: "read-write" | "read-only" | "deny" }> = [
{ shape: "https://github.com/acme/plain", tier: "deny" },
{ shape: "https://github.com/acme/dotgit.git", tier: "read-only" },
{ shape: "https://github.com/acme/slash/", tier: "read-write" },
// .git + trailing slash: bash must strip the slash BEFORE the .git suffix
// (slash-first order), as canonicalizeRemote does.
{ shape: "https://github.com/acme/dotgitslash.git/", tier: "deny" },
// Uppercase .GIT: canonicalizeRemote strips case-insensitively; bash must
// lowercase before the suffix strip or the key keeps a ".git" tail.
{ shape: "https://github.com/ACME/UpperGit.GIT", tier: "read-only" },
{ shape: "git@github.com:acme/scp.git", tier: "deny" },
{ shape: "ssh://git@github.com/acme/sshurl.git", tier: "read-write" },
];
test("normalize <url> prints exactly canonicalizeRemote(url) for every corpus shape", () => {
for (const { shape } of CORPUS) {
const r = run(["normalize", shape]);
expect(r.status).toBe(0);
expect(r.stdout.trim()).toBe(canonicalizeRemote(shape));
}
});
test("a policy set via the script with shape X is found via canonicalizeRemote(X)", () => {
for (const { shape, tier } of CORPUS) {
expect(run(["set", shape, tier]).status).toBe(0);
}
const canon = CORPUS.map((c) => canonicalizeRemote(c.shape));
const verdicts = repoPolicyTierBatch(canon, env());
for (let i = 0; i < CORPUS.length; i++) {
expect(verdicts.get(canon[i])).toEqual({ tier: CORPUS[i].tier });
}
});
test("cross-shape: set through one shape, looked up through another shape of the same repo", () => {
// The store keys on the normalized form, so every spelling of the same
// repo shares one entry — set through scp form, read through https form.
expect(run(["set", "git@github.com:acme/xshape.git", "deny"]).status).toBe(0);
const canon = canonicalizeRemote("https://github.com/ACME/XShape.GIT/");
const verdicts = repoPolicyTierBatch([canon], env());
expect(verdicts.get(canon)).toEqual({ tier: "deny" });
});
});
+157
View File
@@ -0,0 +1,157 @@
/**
* #2516: the brain worktree gbrain indexes must advance on the daily sync
* and the unattended advance path must be SAFE: refuse dirty worktrees,
* refuse anything that is not a worktree of the artifacts repo, and never
* force-remove. (Pre-fix, the worktree only moved when setup-gbrain /
* sync-gbrain / brain-restore ran, so brains silently served stale pages.)
*/
import { describe, test as _test, expect, beforeEach, afterEach } from 'bun:test';
const test = (name: string, fn: any) => _test(name, fn, 30000);
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { spawnSync } from 'child_process';
const ROOT = path.resolve(import.meta.dir, '..');
const BIN = path.join(ROOT, 'bin');
let tmpHome: string;
function run(argv: string[], env: Record<string, string> = {}) {
const full = path.join(BIN, argv[0]);
const res = spawnSync(full, argv.slice(1), {
env: { ...process.env, HOME: tmpHome, GSTACK_HOME: tmpHome, ...env },
encoding: 'utf-8',
cwd: ROOT,
});
return { stdout: res.stdout || '', stderr: res.stderr || '', status: res.status ?? -1 };
}
function git(args: string[], cwd: string) {
const res = spawnSync('git', args, { cwd, encoding: 'utf-8' });
return { stdout: (res.stdout || '').trim(), status: res.status ?? -1 };
}
function commit(cwd: string, msg: string): string {
fs.appendFileSync(path.join(cwd, 'artifact.md'), `${msg}\n`);
git(['add', 'artifact.md'], cwd);
git(['commit', '-q', '-m', msg], cwd);
return git(['rev-parse', 'HEAD'], cwd).stdout;
}
const worktreePath = () => path.join(tmpHome, '.gstack-brain-worktree');
function makeArtifactsRepoWithWorktree(): { head: string } {
git(['init', '-q', '-b', 'main'], tmpHome);
git(['config', 'user.email', 't@t'], tmpHome);
git(['config', 'user.name', 't'], tmpHome);
const head = commit(tmpHome, 'seed');
git(['worktree', 'add', '--detach', worktreePath(), head], tmpHome);
return { head };
}
beforeEach(() => {
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'wtree-adv-home-'));
});
afterEach(() => {
fs.rmSync(tmpHome, { recursive: true, force: true });
});
describe('gstack-gbrain-source-wireup --advance-only (#2516)', () => {
test('advances a clean, behind worktree to the parent HEAD', () => {
makeArtifactsRepoWithWorktree();
const newHead = commit(tmpHome, 'second');
const r = run(['gstack-gbrain-source-wireup', '--advance-only']);
expect(r.status).toBe(0);
expect(r.stdout + r.stderr).toContain('advanced');
expect(git(['rev-parse', 'HEAD'], worktreePath()).stdout).toBe(newHead);
});
test('up-to-date worktree is a no-op', () => {
const { head } = makeArtifactsRepoWithWorktree();
const r = run(['gstack-gbrain-source-wireup', '--advance-only']);
expect(r.status).toBe(0);
expect(r.stdout + r.stderr).toContain('up-to-date');
expect(git(['rev-parse', 'HEAD'], worktreePath()).stdout).toBe(head);
});
test('REFUSES a dirty worktree — local changes are never advanced away', () => {
const { head } = makeArtifactsRepoWithWorktree();
commit(tmpHome, 'second');
fs.writeFileSync(path.join(worktreePath(), 'artifact.md'), 'local edit\n');
const r = run(['gstack-gbrain-source-wireup', '--advance-only']);
expect(r.status).toBe(0); // benign skip, not a hard failure
expect(r.stderr).toContain('local changes');
expect(git(['rev-parse', 'HEAD'], worktreePath()).stdout).toBe(head); // untouched
expect(fs.readFileSync(path.join(worktreePath(), 'artifact.md'), 'utf-8')).toBe('local edit\n');
});
test('REFUSES a path that is not a worktree of the artifacts repo', () => {
makeArtifactsRepoWithWorktree();
// A standalone user repo masquerading as the brain worktree.
const userRepo = fs.mkdtempSync(path.join(os.tmpdir(), 'wtree-adv-user-'));
try {
git(['init', '-q', '-b', 'main'], userRepo);
git(['config', 'user.email', 't@t'], userRepo);
git(['config', 'user.name', 't'], userRepo);
const userHead = commit(userRepo, 'user work');
const r = run(['gstack-gbrain-source-wireup', '--advance-only'], {
GSTACK_BRAIN_WORKTREE: userRepo,
});
expect(r.status).toBe(0);
expect(r.stderr).toContain('not a worktree of');
expect(git(['rev-parse', 'HEAD'], userRepo).stdout).toBe(userHead); // untouched
} finally {
fs.rmSync(userRepo, { recursive: true, force: true });
}
});
test('missing worktree is a benign skip', () => {
git(['init', '-q', '-b', 'main'], tmpHome);
git(['config', 'user.email', 't@t'], tmpHome);
git(['config', 'user.name', 't'], tmpHome);
commit(tmpHome, 'seed');
const r = run(['gstack-gbrain-source-wireup', '--advance-only']);
expect(r.status).toBe(0);
expect(r.stderr).toContain('no managed worktree');
});
test('never contains a force-remove on the advance-only path (static pin)', () => {
// The unattended path must not be able to delete local worktree changes:
// do_advance_only may not call safe_rm_worktree, `worktree remove`, or rm -rf.
const src = fs.readFileSync(path.join(BIN, 'gstack-gbrain-source-wireup'), 'utf-8');
const fn = src.slice(src.indexOf('do_advance_only()'), src.indexOf('do_uninstall()'));
expect(fn.length).toBeGreaterThan(100);
expect(fn).not.toContain('safe_rm_worktree');
expect(fn).not.toContain('worktree remove');
expect(fn).not.toContain('rm -rf');
});
});
describe('brain-sync --once daily advance wiring (#2516)', () => {
test('once advances the worktree behind a 24h attempt stamp', () => {
makeArtifactsRepoWithWorktree();
run(['gstack-config', 'set', 'artifacts_sync_mode', 'artifacts-only']);
const second = commit(tmpHome, 'second');
const r1 = run(['gstack-brain-sync', '--once']);
expect(r1.status).toBe(0);
const stamp = path.join(tmpHome, '.brain-worktree-last-advance');
expect(fs.existsSync(stamp)).toBe(true);
expect(git(['rev-parse', 'HEAD'], worktreePath()).stdout).toBe(second);
// Within the 24h window: parent advances again, --once does NOT re-advance.
const third = commit(tmpHome, 'third');
const r2 = run(['gstack-brain-sync', '--once']);
expect(r2.status).toBe(0);
expect(git(['rev-parse', 'HEAD'], worktreePath()).stdout).toBe(second);
expect(git(['rev-parse', 'HEAD'], worktreePath()).stdout).not.toBe(third);
// Expire the stamp → the next --once advances again.
fs.writeFileSync(stamp, String(Math.floor(Date.now() / 1000) - 90000));
const r3 = run(['gstack-brain-sync', '--once']);
expect(r3.status).toBe(0);
expect(git(['rev-parse', 'HEAD'], worktreePath()).stdout).toBe(third);
});
});
+13 -9
View File
@@ -1958,12 +1958,14 @@ describe('Codex generation (--host codex)', () => {
// ─── Path rewriting regression tests ─────────────────────────
test('sidecar paths point to .agents/skills/gstack/review/ (not gstack-review/)', () => {
// Regression: gen-skill-docs rewrote .claude/skills/review → .agents/skills/gstack-review
// but setup puts sidecars under .agents/skills/gstack/review/. Must match setup layout.
test('sidecar paths resolve through $GSTACK_ROOT (not gstack-review/)', () => {
// #2518: templates now anchor sidecars at the installed skill root
// (~/.claude/skills/gstack/review/...), which the codex path rewrite turns
// into $GSTACK_ROOT/review/... — resolved by the preamble against the
// repo-local .agents root or the global install. The old repo-relative
// form (.claude/skills/review/) only resolved inside gstack's own checkout.
const content = fs.readFileSync(path.join(AGENTS_DIR, 'gstack-review', 'SKILL.md'), 'utf-8');
// Correct: references to sidecar files use gstack/review/ path
expect(content).toContain('.agents/skills/gstack/review/checklist.md');
expect(content).toContain('$GSTACK_ROOT/review/checklist.md');
// design-checklist.md is now referenced via Review Army specialist (Claude only, stripped for Codex)
// Wrong: must NOT reference gstack-review/checklist.md (file doesn't exist there)
expect(content).not.toContain('.agents/skills/gstack-review/checklist.md');
@@ -1981,7 +1983,7 @@ describe('Codex generation (--host codex)', () => {
test('greptile-triage sidecar path is correct', () => {
const content = fs.readFileSync(path.join(AGENTS_DIR, 'gstack-review', 'SKILL.md'), 'utf-8');
if (content.includes('greptile-triage')) {
expect(content).toContain('.agents/skills/gstack/review/greptile-triage.md');
expect(content).toContain('$GSTACK_ROOT/review/greptile-triage.md');
expect(content).not.toContain('.agents/skills/gstack-review/greptile-triage');
}
});
@@ -2023,10 +2025,12 @@ describe('Codex generation (--host codex)', () => {
// ─── Claude output regression guard ─────────────────────────
test('Claude output unchanged: review skill still uses .claude/skills/ paths', () => {
// Codex changes must NOT affect Claude output
test('Claude output uses installed-root review paths (#2518)', () => {
// Codex changes must NOT affect Claude output; the Claude form is the
// installed-root anchor, not the old repo-relative path that only
// resolved inside gstack's own checkout.
const content = fs.readFileSync(path.join(ROOT, 'review', 'SKILL.md'), 'utf-8');
expect(content).toContain('.claude/skills/review/checklist.md');
expect(content).toContain('~/.claude/skills/gstack/review/checklist.md');
expect(content).toContain('~/.claude/skills/gstack');
// Must NOT contain Codex HOST paths. `~/.codex/sessions/` is exempt: the
// timeout-wrapper guidance documents the Codex CLI's own rollout-log
+140
View File
@@ -0,0 +1,140 @@
/**
* gstack-config default-table completeness (gate, free).
*
* Skill preambles read configuration with
*
* VAR=$(gstack-config get <key> 2>/dev/null || echo "<default>")
*
* and that fallback only fires on a NON-ZERO exit. `get` used to answer a key
* it did not know with "" and exit 0, so VAR came back empty and the default
* written right there in the preamble was unreachable. The skill then branched
* on a value it never specified -- "skip entirely if QUESTION_TUNING is false"
* reached with QUESTION_TUNING="".
*
* Four keys skills actually read had no entry in lookup_default and took that
* path: question_tuning, repo_mode, team_mode, transcript_ingest_mode.
*
* Three invariants are pinned so the class cannot reopen:
*
* 1. every key read anywhere in the tree is matched by an arm of the DEFAULTS
* table. Add a `gstack-config get some_new_key` to a preamble without
* adding its default and this test fails. Checked by parsing the case arms
* rather than shelling out per key, which keeps it fast and makes the
* failure name the key.
* 2. a genuinely unknown key exits non-zero, so the caller fallback fires.
* 3. a known key whose default is intentionally empty still exits 0 --
* cross_project_learnings ("unset triggers the first-time prompt") and
* redact_repo_visibility ("empty falls through to gh/glab detection")
* depend on receiving "" successfully.
*/
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 CONFIG_BIN = path.join(ROOT, 'bin', 'gstack-config');
const SELF = 'gstack-config-defaults.test.ts';
// Isolated state dir, so a value the developer happens to have set in their own
// ~/.gstack/config.yaml cannot mask a missing default.
const STATE = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-config-test-'));
function get(key: string): { out: string; code: number } {
const r = spawnSync('bash', [CONFIG_BIN, 'get', key], {
encoding: 'utf-8',
env: { ...process.env, GSTACK_STATE_ROOT: STATE },
});
return { out: r.stdout ?? '', code: r.status ?? -1 };
}
/** Case-arm patterns of lookup_default, in order, excluding the catch-all. */
function defaultArms(): string[] {
const src = fs.readFileSync(CONFIG_BIN, 'utf-8');
const body = src.slice(src.indexOf('lookup_default()'));
const end = body.indexOf('\n}');
const arms: string[] = [];
// e.g. ` proactive) echo "true" ;;` or ` user_slug_at_*) echo "" ;;`
for (const m of body.slice(0, end).matchAll(/^\s{4}([a-zA-Z0-9_*]+)\)/gm)) {
if (m[1] !== '*') arms.push(m[1]);
}
return arms;
}
function isCovered(key: string, arms: string[]): boolean {
return arms.some((a) =>
a.endsWith('*') ? key.startsWith(a.slice(0, -1)) : key === a,
);
}
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.next']);
/** Every `gstack-config get <key>` call site in the tree. */
function keysReadInTree(): string[] {
const keys = new Set<string>();
// [ \t]+ rather than \s+: \s crosses newlines and would pair a trailing
// "gstack-config get" with the first word of the next line.
const re = /gstack-config["']?[ \t]+get[ \t]+([a-zA-Z0-9_]+)/g;
const stack = [ROOT];
while (stack.length) {
const cur = stack.pop()!;
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(cur, { withFileTypes: true });
} catch {
continue;
}
for (const ent of entries) {
if (SKIP_DIRS.has(ent.name) || ent.isSymbolicLink()) continue;
const full = path.join(cur, ent.name);
if (ent.isDirectory()) {
stack.push(full);
continue;
}
// Skip this file: its own prose cites example keys.
if (ent.name === SELF) continue;
if (!/\.(md|ts|sh)$|^gstack-[a-z-]+$/.test(ent.name)) continue;
let text: string;
try {
text = fs.readFileSync(full, 'utf-8');
} catch {
continue;
}
for (const m of text.matchAll(re)) keys.add(m[1]);
}
}
return [...keys].sort();
}
describe('gstack-config defaults (gate, free)', () => {
test('every key read in the tree is covered by the DEFAULTS table', () => {
const arms = defaultArms();
expect(arms.length).toBeGreaterThan(10); // the parse actually found the table
const uncovered = keysReadInTree().filter((k) => !isCovered(k, arms));
expect(uncovered).toEqual([]);
});
test('an unknown key exits non-zero, so the caller fallback fires', () => {
const r = get('definitely_not_a_gstack_key_9f3a');
expect(r.code).not.toBe(0);
expect(r.out).toBe('');
});
test('a known key whose default is intentionally empty still exits 0', () => {
// repo_mode is in this class BY CONTRACT: gstack-repo-mode treats any
// non-empty answer as a user override and skips classification, so a
// synthesized "unknown" default would turn the classifier into dead code
// (caught live by test/gstack-repo-mode.test.ts during the wave).
for (const key of ['cross_project_learnings', 'salience_allowlist', 'redact_repo_visibility', 'repo_mode']) {
expect({ key, ...get(key) }).toEqual({ key, out: '', code: 0 });
}
});
test('the regressed keys resolve to the values their callers assume', () => {
expect(get('question_tuning').out).toBe('false');
expect(get('team_mode').out).toBe('false');
expect(get('transcript_ingest_mode').out).toBe('off');
});
});
+452 -5
View File
@@ -93,7 +93,7 @@ describe("gstack-memory-ingest CLI", () => {
const session = `{"type":"user","message":{"role":"user","content":"hello"},"timestamp":"${new Date().toISOString()}","cwd":"/tmp/x"}\n{"type":"assistant","message":{"role":"assistant","content":"hi"},"timestamp":"${new Date().toISOString()}"}\n`;
writeClaudeCodeSession(home, "tmp-x", "abc123", session);
const r = runScript(["--probe"], { HOME: home, GSTACK_HOME: gstackHome });
const r = runScript(["--probe", "--include-unattributed"], { HOME: home, GSTACK_HOME: gstackHome });
expect(r.exitCode).toBe(0);
expect(r.stdout).toContain("Total files in window: 1");
expect(r.stdout).toContain("transcript");
@@ -109,7 +109,7 @@ describe("gstack-memory-ingest CLI", () => {
const session = `{"type":"session_meta","payload":{"id":"sess-xyz","cwd":"/tmp/x","git":{"repository_url":"https://github.com/foo/bar"}},"timestamp":"${today.toISOString()}"}\n`;
writeCodexSession(home, ymd, session);
const r = runScript(["--probe"], { HOME: home, GSTACK_HOME: gstackHome });
const r = runScript(["--probe", "--include-unattributed"], { HOME: home, GSTACK_HOME: gstackHome });
expect(r.exitCode).toBe(0);
expect(r.stdout).toContain("Total files in window: 1");
rmSync(home, { recursive: true, force: true });
@@ -269,7 +269,7 @@ describe("internal: parseTranscriptJsonl + buildTranscriptPage shape", () => {
mkdirSync(projDir, { recursive: true });
writeFileSync(join(projDir, "abc123.jsonl"), content, "utf-8");
const r = runScript(["--probe"], { HOME: home, GSTACK_HOME: join(home, ".gstack") });
const r = runScript(["--probe", "--include-unattributed"], { HOME: home, GSTACK_HOME: join(home, ".gstack") });
expect(r.exitCode).toBe(0);
expect(r.stdout).toContain("Total files in window: 1");
@@ -288,7 +288,7 @@ describe("internal: parseTranscriptJsonl + buildTranscriptPage shape", () => {
`{"type":"assistant","message":{"role":"assistant","content":"this is truncat`; // no closing brace + no newline
writeFileSync(join(projDir, "trunc.jsonl"), content, "utf-8");
const r = runScript(["--probe"], { HOME: home, GSTACK_HOME: join(home, ".gstack") });
const r = runScript(["--probe", "--include-unattributed"], { HOME: home, GSTACK_HOME: join(home, ".gstack") });
// Should not crash; should report 1 transcript
expect(r.exitCode).toBe(0);
expect(r.stdout).toContain("Total files in window: 1");
@@ -300,9 +300,16 @@ describe("internal: parseTranscriptJsonl + buildTranscriptPage shape", () => {
describe("gstack-memory-ingest --limit", () => {
it("respects --limit by stopping after N writes (mocked via --probe shortcut)", () => {
const r = runScript(["--probe", "--limit", "1"]);
// Hermetic home: against the operator's real HOME this walked the whole
// transcript corpus (and, post policy-parity, batch-checked its real
// policy store), making a pure arg-parsing assertion slow and flaky.
const home = makeTestHome();
const gstackHome = join(home, ".gstack");
mkdirSync(gstackHome, { recursive: true });
const r = runScript(["--probe", "--limit", "1"], { HOME: home, GSTACK_HOME: gstackHome });
// --limit doesn't apply to probe but argument should parse without error
expect(r.exitCode).toBe(0);
rmSync(home, { recursive: true, force: true });
});
it("rejects --limit 0 with exit 1", () => {
@@ -861,3 +868,443 @@ describe("#2105 codex response_item rollout shape", () => {
rmSync(dir, { recursive: true, force: true });
});
});
// ── #2394: --probe counts post-attribution, matching what --bulk would write ─
describe("#2394: probe applies the same attribution gate as prepare", () => {
function makeAttributableCwd(home: string): string {
const repo = join(home, "work", "attributable-repo");
mkdirSync(repo, { recursive: true });
spawnSync("git", ["-C", repo, "init", "-q"], { encoding: "utf-8" });
spawnSync("git", ["-C", repo, "remote", "add", "origin", "https://github.com/foo/bar.git"], { encoding: "utf-8" });
return repo;
}
function writeMixedCorpus(home: string): void {
const attributableCwd = makeAttributableCwd(home);
const ts = new Date().toISOString();
writeClaudeCodeSession(
home, "work-attributable", "attr1",
`{"type":"user","message":{"role":"user","content":"hello"},"timestamp":"${ts}","cwd":"${attributableCwd.replace(/\\/g, "\\\\")}"}\n`,
);
writeClaudeCodeSession(
home, "tmp-nowhere", "unattr1",
`{"type":"user","message":{"role":"user","content":"hello"},"timestamp":"${ts}","cwd":"${join(home, "not-a-repo").replace(/\\/g, "\\\\")}"}\n`,
);
mkdirSync(join(home, "not-a-repo"), { recursive: true });
}
it("probe reports post-attribution counts and names what it skipped", () => {
const home = makeTestHome();
const gstackHome = join(home, ".gstack");
mkdirSync(gstackHome, { recursive: true });
writeMixedCorpus(home);
const r = runScript(["--probe"], { HOME: home, GSTACK_HOME: gstackHome });
expect(r.exitCode).toBe(0);
// Post-attribution: only the transcript whose cwd resolves to a remote.
expect(r.stdout).toContain("Total files in window: 1");
// The excluded remainder is visible, never silent.
expect(r.stdout).toContain("Skipped (unattributed): 1");
rmSync(home, { recursive: true, force: true });
});
it("--include-unattributed restores raw counts", () => {
const home = makeTestHome();
const gstackHome = join(home, ".gstack");
mkdirSync(gstackHome, { recursive: true });
writeMixedCorpus(home);
const r = runScript(["--probe", "--include-unattributed"], { HOME: home, GSTACK_HOME: gstackHome });
expect(r.exitCode).toBe(0);
expect(r.stdout).toContain("Total files in window: 2");
rmSync(home, { recursive: true, force: true });
});
it("parity: probe post-attribution count equals what prepare actually processes", () => {
const home = makeTestHome();
const gstackHome = join(home, ".gstack");
mkdirSync(gstackHome, { recursive: true });
writeMixedCorpus(home);
const probe = runScript(["--probe"], { HOME: home, GSTACK_HOME: gstackHome });
expect(probe.exitCode).toBe(0);
const probeNew = Number((probe.stdout.match(/New \(never ingested\):\s+(\d+)/) || [])[1]);
expect(probeNew).toBe(1);
// Same stage on the ingest side: the transcripts that reach the import
// step (written + failed) are exactly the ones that passed the shared
// attribution gate in preparePages. No gbrain is configured in this
// hermetic env, so the attributable transcript FAILS at import — that is
// fine: parity is a prepare-stage invariant (probe post-attribution ==
// prepare post-attribution), deliberately NOT == final written (#2394).
const inc = runScript(["--incremental", "--quiet"], { HOME: home, GSTACK_HOME: gstackHome });
const m = inc.stderr.match(/(\d+) written, (\d+) failed/) || inc.stdout.match(/(\d+) written, (\d+) failed/);
expect(m).not.toBeNull();
const reachedImport = Number(m![1]) + Number(m![2]);
expect(reachedImport).toBe(probeNew);
rmSync(home, { recursive: true, force: true });
});
it("a multi-MB transcript is still classified correctly (bounded probe read)", () => {
// The probe reads a BOUNDED 256KB prefix, never the whole file (plan C7).
// The cwd sits on the first line; >1MB of filler follows. Classification
// must come out attributable — and stay cheap on real multi-MB corpora.
const home = makeTestHome();
const gstackHome = join(home, ".gstack");
mkdirSync(gstackHome, { recursive: true });
const attributableCwd = join(home, "work", "attributable-repo");
mkdirSync(attributableCwd, { recursive: true });
spawnSync("git", ["-C", attributableCwd, "init", "-q"], { encoding: "utf-8" });
spawnSync("git", ["-C", attributableCwd, "remote", "add", "origin", "https://github.com/foo/bar.git"], { encoding: "utf-8" });
const ts = new Date().toISOString();
const cwdLine = `{"type":"user","message":{"role":"user","content":"hello"},"timestamp":"${ts}","cwd":"${attributableCwd.replace(/\\/g, "\\\\")}"}\n`;
const filler = `{"type":"assistant","message":{"role":"assistant","content":"${"x".repeat(1000)}"}}\n`;
const body = cwdLine + filler.repeat(1100); // > 1MB after the cwd line
expect(body.length).toBeGreaterThan(1024 * 1024);
writeClaudeCodeSession(home, "work-attributable", "big1", body);
const r = runScript(["--probe"], { HOME: home, GSTACK_HOME: gstackHome });
expect(r.exitCode).toBe(0);
expect(r.stdout).toContain("Total files in window: 1");
expect(r.stdout).not.toContain("Skipped (unattributed)");
rmSync(home, { recursive: true, force: true });
});
it("Codex format: session_meta cwd attributes the transcript in the probe", () => {
const home = makeTestHome();
const gstackHome = join(home, ".gstack");
mkdirSync(gstackHome, { recursive: true });
const attributableCwd = makeAttributableCwd(home);
const today = new Date();
const ymd = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
const session = `{"type":"session_meta","payload":{"id":"sess-meta-cwd","cwd":"${attributableCwd.replace(/\\/g, "\\\\")}"},"timestamp":"${today.toISOString()}"}\n`;
writeCodexSession(home, ymd, session);
const r = runScript(["--probe"], { HOME: home, GSTACK_HOME: gstackHome });
expect(r.exitCode).toBe(0);
expect(r.stdout).toContain("Total files in window: 1");
expect(r.stdout).not.toContain("Skipped (unattributed)");
rmSync(home, { recursive: true, force: true });
});
it("parity: a Codex cwd appearing only on a LATER record is unattributed in probe AND prepare", () => {
// parseTranscriptJsonl reads Codex cwd from the session_meta FIRST record
// ONLY. The probe mirrors those exact rules — the pre-fix probe scanned
// every line for any cwd and DIVERGED on this shape (probe said
// attributable, prepare said not).
const home = makeTestHome();
const gstackHome = join(home, ".gstack");
mkdirSync(gstackHome, { recursive: true });
const attributableCwd = makeAttributableCwd(home);
const today = new Date();
const ymd = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
const session =
`{"type":"session_meta","payload":{"id":"sess-late-cwd"},"timestamp":"${today.toISOString()}"}\n` +
`{"type":"response_item","payload":{"type":"message","role":"user","content":[{"text":"hi"}]},"cwd":"${attributableCwd.replace(/\\/g, "\\\\")}"}\n`;
writeCodexSession(home, ymd, session);
const probe = runScript(["--probe"], { HOME: home, GSTACK_HOME: gstackHome });
expect(probe.exitCode).toBe(0);
expect(probe.stdout).toContain("Total files in window: 0");
expect(probe.stdout).toContain("Skipped (unattributed): 1");
// Prepare agrees: nothing reaches the import stage (written + failed = 0)
// and the skip is attributed to the same gate.
const inc = runScript(["--incremental"], { HOME: home, GSTACK_HOME: gstackHome });
expect(inc.exitCode).toBe(0);
const written = Number((inc.stdout.match(/written:\s+(\d+)/) || [])[1]);
const failed = Number((inc.stdout.match(/failed:\s+(\d+)/) || [])[1]);
const unattrib = Number((inc.stdout.match(/skipped \(unattrib\):\s+(\d+)/) || [])[1]);
expect(written + failed).toBe(0);
expect(unattrib).toBe(1);
rmSync(home, { recursive: true, force: true });
});
});
// ── #2392: transcript ingest honors the per-remote trust policy ─────────────
//
// The same store the code-import gate honors (bin/gstack-gbrain-sync.ts):
// tier `deny` and `read-only` transcripts are skipped with their own counters;
// a store that EXISTS but can't be read is a hard error before any writes
// (never a silent bypass of a set policy); no store at all = zero policy work.
// The policy store is seeded through the REAL bin/gstack-gbrain-repo-policy
// script (its `set` verb owns the file schema + URL normalization).
describe("#2392: transcript ingest honors per-remote trust policy", () => {
const POLICY_BIN = join(import.meta.dir, "..", "bin", "gstack-gbrain-repo-policy");
/** Attributable temp git repo whose origin points at `remoteUrl`. */
function makeRepoWithRemote(home: string, name: string, remoteUrl: string): string {
const repo = join(home, "work", name);
mkdirSync(repo, { recursive: true });
spawnSync("git", ["-C", repo, "init", "-q"], { encoding: "utf-8" });
spawnSync("git", ["-C", repo, "remote", "add", "origin", remoteUrl], { encoding: "utf-8" });
return repo;
}
function writeSessionForRepo(home: string, projectName: string, sessionId: string, cwd: string): void {
const record = JSON.stringify({
type: "user",
message: { role: "user", content: `hello from ${sessionId}` },
timestamp: new Date().toISOString(),
cwd,
});
writeClaudeCodeSession(home, projectName, sessionId, record + "\n");
}
function setPolicy(gstackHome: string, url: string, tier: string): void {
const r = spawnSync(POLICY_BIN, ["set", url, tier], {
encoding: "utf-8",
env: { ...process.env, GSTACK_HOME: gstackHome },
});
expect(r.status).toBe(0);
}
function stateSessions(gstackHome: string): string[] {
const statePath = join(gstackHome, ".transcript-ingest-state.json");
if (!existsSync(statePath)) return [];
return Object.keys(JSON.parse(readFileSync(statePath, "utf-8")).sessions || {});
}
it("(a) deny remote's transcript is skipped and counted as skipped_policy_deny", () => {
const home = makeTestHome();
const gstackHome = join(home, ".gstack");
mkdirSync(gstackHome, { recursive: true });
const { binDir, logFile } = installFakeGbrain(home);
const denyCwd = makeRepoWithRemote(home, "denied", "https://github.com/denyme/denied.git");
const okCwd = makeRepoWithRemote(home, "allowed", "https://github.com/okorg/okrepo.git");
writeSessionForRepo(home, "work-denied", "denysess1", denyCwd);
writeSessionForRepo(home, "work-allowed", "oksess1", okCwd);
setPolicy(gstackHome, "https://github.com/denyme/denied.git", "deny");
const r = runScript(["--bulk", "--quiet"], {
HOME: home,
GSTACK_HOME: gstackHome,
PATH: `${binDir}:${process.env.PATH || ""}`,
});
expect(r.exitCode).toBe(0);
expect(r.stdout).toMatch(/written:\s+1/);
expect(r.stdout).toMatch(/skipped \(policy deny\):\s+1/);
expect(r.stdout).not.toMatch(/skipped \(policy read-only\)/);
// Only the allowed session was imported + state-recorded.
expect(existsSync(logFile)).toBe(true);
const sessions = stateSessions(gstackHome);
expect(sessions.length).toBe(1);
expect(sessions[0]).toContain("oksess1");
rmSync(home, { recursive: true, force: true });
});
it("(b) read-only remote's transcript is skipped and counted as skipped_policy_readonly", () => {
const home = makeTestHome();
const gstackHome = join(home, ".gstack");
mkdirSync(gstackHome, { recursive: true });
const { binDir } = installFakeGbrain(home);
const roCwd = makeRepoWithRemote(home, "readonly", "https://github.com/roorg/rorepo.git");
const okCwd = makeRepoWithRemote(home, "allowed", "https://github.com/okorg/okrepo.git");
writeSessionForRepo(home, "work-readonly", "rosess1", roCwd);
writeSessionForRepo(home, "work-allowed", "oksess1", okCwd);
setPolicy(gstackHome, "https://github.com/roorg/rorepo.git", "read-only");
const r = runScript(["--bulk", "--quiet"], {
HOME: home,
GSTACK_HOME: gstackHome,
PATH: `${binDir}:${process.env.PATH || ""}`,
});
expect(r.exitCode).toBe(0);
expect(r.stdout).toMatch(/written:\s+1/);
expect(r.stdout).toMatch(/skipped \(policy read-only\):\s+1/);
const sessions = stateSessions(gstackHome);
expect(sessions.length).toBe(1);
expect(sessions[0]).toContain("oksess1");
rmSync(home, { recursive: true, force: true });
});
it("(c) read-write remote's transcript is ingested (reaches gbrain import)", () => {
const home = makeTestHome();
const gstackHome = join(home, ".gstack");
mkdirSync(gstackHome, { recursive: true });
const { binDir, logFile } = installFakeGbrain(home);
const rwCwd = makeRepoWithRemote(home, "readwrite", "https://github.com/rworg/rwrepo.git");
writeSessionForRepo(home, "work-readwrite", "rwsess1", rwCwd);
setPolicy(gstackHome, "https://github.com/rworg/rwrepo.git", "read-write");
const r = runScript(["--bulk", "--quiet"], {
HOME: home,
GSTACK_HOME: gstackHome,
PATH: `${binDir}:${process.env.PATH || ""}`,
});
expect(r.exitCode).toBe(0);
expect(r.stdout).toMatch(/written:\s+1/);
expect(r.stdout).not.toMatch(/skipped \(policy/);
// gbrain import ran exactly once — the page reached the import stage.
const calls = readFileSync(logFile, "utf-8").trim().split("\n").filter(Boolean);
expect(calls.length).toBe(1);
expect(stateSessions(gstackHome).length).toBe(1);
rmSync(home, { recursive: true, force: true });
});
it("(d) corrupted store: hard error before any writes, message names recovery", () => {
const home = makeTestHome();
const gstackHome = join(home, ".gstack");
mkdirSync(gstackHome, { recursive: true });
const { binDir, logFile } = installFakeGbrain(home);
const cwd = makeRepoWithRemote(home, "somerepo", "https://github.com/some/repo.git");
writeSessionForRepo(home, "work-somerepo", "somesess1", cwd);
// Corrupt store — the batch verb refuses (exit 2), the client classifies
// `unreadable`, and ingest must abort rather than bypass a set policy.
writeFileSync(join(gstackHome, "gbrain-repo-policy.json"), "not valid json{", "utf-8");
const r = runScript(["--bulk", "--quiet"], {
HOME: home,
GSTACK_HOME: gstackHome,
PATH: `${binDir}:${process.env.PATH || ""}`,
});
expect(r.exitCode).toBe(1);
expect(r.stderr).toMatch(/\[memory-ingest\] ERR:.*repo policy store exists/);
expect(r.stderr).toContain("gstack-gbrain-repo-policy list");
expect(r.stderr).toContain("/setup-gbrain");
// Nothing written: no gbrain import call, no state file, store untouched.
expect(existsSync(logFile)).toBe(false);
expect(stateSessions(gstackHome).length).toBe(0);
expect(readFileSync(join(gstackHome, "gbrain-repo-policy.json"), "utf-8")).toBe("not valid json{");
rmSync(home, { recursive: true, force: true });
});
it("(e) no store at all: no policy filtering, transcript ingests normally", () => {
const home = makeTestHome();
const gstackHome = join(home, ".gstack");
mkdirSync(gstackHome, { recursive: true });
const { binDir } = installFakeGbrain(home);
const cwd = makeRepoWithRemote(home, "freerepo", "https://github.com/free/repo.git");
writeSessionForRepo(home, "work-freerepo", "freesess1", cwd);
const r = runScript(["--bulk", "--quiet"], {
HOME: home,
GSTACK_HOME: gstackHome,
PATH: `${binDir}:${process.env.PATH || ""}`,
});
expect(r.exitCode).toBe(0);
expect(r.stdout).toMatch(/written:\s+1/);
expect(r.stdout).not.toMatch(/skipped \(policy/);
expect(stateSessions(gstackHome).length).toBe(1);
rmSync(home, { recursive: true, force: true });
});
it("(f) probe policy parity: a denied remote's transcript lands in skipped_policy_deny, not new_count", () => {
// --probe used to count policy-denied transcripts as ingestible (it only
// applied attribution), so its numbers overstated what --bulk would write.
const home = makeTestHome();
const gstackHome = join(home, ".gstack");
mkdirSync(gstackHome, { recursive: true });
const denyCwd = makeRepoWithRemote(home, "denied", "https://github.com/denyme/denied.git");
writeSessionForRepo(home, "work-denied", "denysess1", denyCwd);
setPolicy(gstackHome, "https://github.com/denyme/denied.git", "deny");
const r = runScript(["--probe"], { HOME: home, GSTACK_HOME: gstackHome });
expect(r.exitCode).toBe(0);
expect(r.stdout).toContain("Total files in window: 0");
expect(r.stdout).toMatch(/New \(never ingested\):\s+0/);
expect(r.stdout).toMatch(/Skipped \(policy deny\):\s+1/);
expect(r.stdout).not.toMatch(/Skipped \(policy read-only\)/);
rmSync(home, { recursive: true, force: true });
});
it("(g) probe policy parity: a read-only remote's transcript lands in skipped_policy_readonly", () => {
const home = makeTestHome();
const gstackHome = join(home, ".gstack");
mkdirSync(gstackHome, { recursive: true });
const roCwd = makeRepoWithRemote(home, "readonly", "https://github.com/roorg/rorepo.git");
writeSessionForRepo(home, "work-readonly", "rosess1", roCwd);
setPolicy(gstackHome, "https://github.com/roorg/rorepo.git", "read-only");
const r = runScript(["--probe"], { HOME: home, GSTACK_HOME: gstackHome });
expect(r.exitCode).toBe(0);
expect(r.stdout).toContain("Total files in window: 0");
expect(r.stdout).toMatch(/Skipped \(policy read-only\):\s+1/);
rmSync(home, { recursive: true, force: true });
});
it("(h) --limit counts policy-PERMITTED pages only: a denied-first corpus still writes the allowed page", () => {
// Walk order is deterministic here: Claude Code projects are walked
// BEFORE Codex sessions (walkAllSources), so the DENIED transcript is
// prepared first. Pre-fix, --limit 1 was applied to the unfiltered
// prepared array — the denied record consumed the limit and the permitted
// one starved (written: 0).
const home = makeTestHome();
const gstackHome = join(home, ".gstack");
mkdirSync(gstackHome, { recursive: true });
const { binDir } = installFakeGbrain(home);
const denyCwd = makeRepoWithRemote(home, "denied", "https://github.com/denyme/denied.git");
writeSessionForRepo(home, "work-denied", "denysess1", denyCwd); // Claude Code: walked first
const okCwd = makeRepoWithRemote(home, "allowed", "https://github.com/okorg/okrepo.git");
const today = new Date();
const ymd = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
writeCodexSession(
home, ymd,
`{"type":"session_meta","payload":{"id":"oksess-codex","cwd":"${okCwd.replace(/\\/g, "\\\\")}"},"timestamp":"${today.toISOString()}"}\n`,
);
setPolicy(gstackHome, "https://github.com/denyme/denied.git", "deny");
const r = runScript(["--bulk", "--quiet", "--limit", "1"], {
HOME: home,
GSTACK_HOME: gstackHome,
PATH: `${binDir}:${process.env.PATH || ""}`,
});
expect(r.exitCode).toBe(0);
expect(r.stdout).toMatch(/written:\s+1/);
expect(r.stdout).toMatch(/skipped \(policy deny\):\s+1/);
// The page that landed is the PERMITTED one (the Codex session), not
// whichever record happened to be walked first.
const sessions = stateSessions(gstackHome);
expect(sessions.length).toBe(1);
expect(sessions[0]).toContain("rollout-");
rmSync(home, { recursive: true, force: true });
});
it("artifacts are never policy-filtered, even when their project's remote is denied", () => {
const home = makeTestHome();
const gstackHome = join(home, ".gstack");
mkdirSync(join(gstackHome, "projects", "denyme-denied"), { recursive: true });
const { binDir } = installFakeGbrain(home);
// A learning artifact under a project slug matching a denied remote —
// the policy is keyed by git remote, which artifacts don't have.
writeFileSync(join(gstackHome, "projects", "denyme-denied", "learnings.jsonl"), '{"key":"a","insight":"b"}\n');
setPolicy(gstackHome, "https://github.com/denyme/denied.git", "deny");
const r = runScript(["--bulk", "--quiet"], {
HOME: home,
GSTACK_HOME: gstackHome,
PATH: `${binDir}:${process.env.PATH || ""}`,
});
expect(r.exitCode).toBe(0);
expect(r.stdout).toMatch(/written:\s+1/);
expect(r.stdout).not.toMatch(/skipped \(policy/);
rmSync(home, { recursive: true, force: true });
});
});
+397 -10
View File
@@ -5,7 +5,7 @@
import { test, expect, describe } from "bun:test";
import { execFileSync } from "node:child_process";
import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from "node:fs";
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
@@ -385,19 +385,54 @@ describe("offline output contract (what /ship branches on, #2545)", () => {
// 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.
// "verify no sibling holds it" prompt.
//
// Both tests run the CLI in a LOCAL FIXTURE repo, never the checkout it
// lives in: in the real checkout the git fallback does a live
// `ls-remote origin` and reads the real branch census, which made this test
// depend on the operator's network — and on a shallow CI clone it fetched
// the remote's every branch (the shard-deadline hang fixed alongside this).
const NEXTVER = join(import.meta.dir, "..", "bin", "gstack-next-version");
function fixtureRepo(): { root: string; work: string } {
const root = mkdtempSync(join(tmpdir(), "nextver-contract-"));
// detectHost() sniffs "github.com" in the origin URL STRING before any
// gh/glab auth probe — a bare origin at a path containing github.com
// pins host:"github" identically on every machine (an auth-probe
// fallthrough once made this test pass via glab locally and read
// host:"unknown" on CI) while keeping ls-remote/fetch fully local.
const bare = join(root, "github.com", "origin.git");
mkdirSync(bare, { recursive: true });
Bun.spawnSync(["git", "init", "-q", "--bare", "-b", "main", bare]);
const work = join(root, "work");
mkdirSync(work);
const git = (...args: string[]) =>
Bun.spawnSync(["git", "-c", "user.email=t@t", "-c", "user.name=t", ...args], { cwd: work });
git("init", "-q", "-b", "main");
writeFileSync(join(work, "VERSION"), "1.0.0.0\n");
git("add", "-A");
git("commit", "-qm", "v1.0.0.0 chore: base");
git("remote", "add", "origin", bare);
git("push", "-q", "origin", "main");
return { root, work };
}
test("emits fallback:'git' and still returns a version when gh fails", async () => {
// Stub `gh` always fails — what an expired token or an offline laptop
// looks like from here. No origin remote → ls-remote fails fast and the
// allocation comes from local refs, never the network.
const stubDir = mkdtempSync(join(tmpdir(), "nextver-stubgh-"));
const { root, work } = fixtureRepo();
writeFileSync(join(stubDir, "gh"), "#!/bin/sh\nexit 1\n", { mode: 0o755 });
const proc = Bun.spawnSync(
["bun", "run", "./bin/gstack-next-version", "--base", "main",
["bun", "run", NEXTVER, "--base", "main",
"--bump", "patch", "--current-version", "1.0.0.0", "--workspace-root", "null"],
{ env: { ...process.env, PATH: `${stubDir}:${process.env.PATH}` } },
{ cwd: work, env: { ...process.env, PATH: `${stubDir}:${process.env.PATH}` } },
);
rmSync(stubDir, { recursive: true, force: true });
rmSync(root, { recursive: true, force: true });
const out = JSON.parse(new TextDecoder().decode(proc.stdout));
expect(out.host).toBe("github"); // pinned by the fixture's URL sniff, not auth probes
expect(out.offline).toBe(true);
expect(out.fallback).toBe("git");
// The whole point: degraded queue view, NOT a degraded allocation.
@@ -406,13 +441,28 @@ describe("offline output contract (what /ship branches on, #2545)", () => {
}, 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"],
// Stub `gh` SUCCEEDS (empty PR queue) — the online path asserted
// deterministically instead of only when the operator happens to be
// authed. Before this stub the test silently no-opped on CI.
const stubDir = mkdtempSync(join(tmpdir(), "nextver-stubgh-ok-"));
const { root, work } = fixtureRepo();
writeFileSync(
join(stubDir, "gh"),
'#!/bin/sh\ncase "$1" in\n pr) echo "[]" ;;\n repo) echo "testowner" ;;\n *) exit 0 ;;\nesac\n',
{ mode: 0o755 },
);
const proc = Bun.spawnSync(
["bun", "run", NEXTVER, "--base", "main",
"--bump", "patch", "--current-version", "1.0.0.0", "--workspace-root", "null"],
{ cwd: work, env: { ...process.env, PATH: `${stubDir}:${process.env.PATH}` } },
);
rmSync(stubDir, { recursive: true, force: true });
rmSync(root, { recursive: true, force: true });
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.host).toBe("github"); // pinned by the fixture's URL sniff, not auth probes
expect(out.offline).toBe(false);
expect(out.fallback).toBe(null);
expect(out.version).toMatch(/^\d+\.\d+\.\d+\.\d+$/);
}, 30000);
});
@@ -542,6 +592,343 @@ describe("fetchGitClaimed (offline allocation — the anti-duplicate fallback, #
});
});
describe("fetchGitClaimed — non-mutating live remote query (ls-remote first)", () => {
// The degraded git-fallback used to count EVERY remote-tracking ref on EVERY
// remote: branches deleted on origin (stale local refs) and an unrelated
// `upstream` remote's branches all inflated the claim set, pushing the
// allocation past the real queue. `git ls-remote --heads origin` returns the
// remote's LIVE branch list with zero local mutation — a path/file remote
// answers it offline, which is exactly what these fixtures use.
function git(cwd: string, ...args: string[]) {
return Bun.spawnSync(["git", "-c", "user.email=t@t", "-c", "user.name=t", ...args], { cwd });
}
// Local origin with: main (0.1.66.0), sibling (0.1.67.0, live claim), and
// dead (0.1.98.0) — deleted on origin AFTER the clone, so the clone keeps a
// stale refs/remotes/origin/dead. Plus a second remote's stale claim ref.
function liveFixture(): { root: string; clone: string } {
const root = mkdtempSync(join(tmpdir(), "nextver-lsremote-"));
const origin = join(root, "origin");
mkdirSync(origin);
git(origin, "init", "-q", "-b", "main");
writeFileSync(join(origin, "VERSION"), "0.1.66.0\n");
git(origin, "add", "-A");
git(origin, "commit", "-qm", "v0.1.66.0 chore: base");
git(origin, "checkout", "-q", "-b", "sibling");
writeFileSync(join(origin, "VERSION"), "0.1.67.0\n");
git(origin, "add", "-A");
git(origin, "commit", "-qm", "v0.1.67.0 feat: sibling claimed this");
git(origin, "checkout", "-q", "-b", "dead");
writeFileSync(join(origin, "VERSION"), "0.1.98.0\n");
git(origin, "add", "-A");
git(origin, "commit", "-qm", "v0.1.98.0 feat: deleted later");
git(origin, "checkout", "-q", "main");
const clone = join(root, "clone");
git(root, "clone", "-q", origin, clone);
// Deleted on the REMOTE after the clone — the stale local ref survives.
git(origin, "branch", "-qD", "dead");
// A second remote carrying a stale claim branch: must never be counted.
git(clone, "checkout", "-q", "-b", "tmp-upstream");
writeFileSync(join(clone, "VERSION"), "0.1.99.0\n");
git(clone, "add", "-A");
git(clone, "commit", "-qm", "v0.1.99.0 upstream stale claim");
const upSha = new TextDecoder().decode(git(clone, "rev-parse", "HEAD").stdout).trim();
git(clone, "checkout", "-q", "main");
git(clone, "branch", "-qD", "tmp-upstream");
git(clone, "update-ref", "refs/remotes/upstream/stale", upSha);
return { root, clone };
}
test("live path: only branches that exist on origin RIGHT NOW are claims", () => {
const { root, clone } = liveFixture();
const cwd = process.cwd();
try {
process.chdir(clone);
const warnings: string[] = [];
const claims = fetchGitClaimed("main", "VERSION", warnings);
const versions = claims.map((c) => c.version);
expect(versions).toContain("0.1.67.0"); // live sibling claim
expect(versions).not.toContain("0.1.98.0"); // deleted on origin — stale local ref ignored
expect(versions).not.toContain("0.1.99.0"); // second remote's refs are not our queue
// The live path emits no staleness warning.
expect(warnings.join(" ")).not.toContain("ls-remote");
} finally {
process.chdir(cwd);
rmSync(root, { recursive: true, force: true });
}
});
test("zero local mutation: the stale remote-tracking ref survives the query", () => {
// ls-remote reads the remote without fetch/prune — an allocator run must
// never rewrite local refs as a side effect.
const { root, clone } = liveFixture();
const cwd = process.cwd();
try {
process.chdir(clone);
fetchGitClaimed("main", "VERSION", []);
const ref = git(clone, "rev-parse", "--verify", "-q", "refs/remotes/origin/dead");
expect(ref.exitCode).toBe(0);
} finally {
process.chdir(cwd);
rmSync(root, { recursive: true, force: true });
}
});
test("fallback: ls-remote failure uses LOCAL refs/remotes/origin only, with a staleness warning", () => {
// No origin remote configured at all — ls-remote must fail, and the
// fallback must scan refs/remotes/origin ONLY (never other remotes).
const dir = mkdtempSync(join(tmpdir(), "nextver-lsfallback-"));
const cwd = process.cwd();
try {
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");
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 sibSha = new TextDecoder().decode(git(dir, "rev-parse", "HEAD").stdout).trim();
git(dir, "checkout", "-q", "-b", "stale2");
writeFileSync(join(dir, "VERSION"), "0.1.99.0\n");
git(dir, "add", "-A");
git(dir, "commit", "-qm", "v0.1.99.0 upstream stale claim");
const upSha = new TextDecoder().decode(git(dir, "rev-parse", "HEAD").stdout).trim();
git(dir, "checkout", "-q", "main");
git(dir, "update-ref", "refs/remotes/origin/sibling", sibSha);
git(dir, "update-ref", "refs/remotes/upstream/stale", upSha);
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"); // origin's local snapshot still counts
expect(versions).not.toContain("0.1.99.0"); // upstream remote is ignored
expect(warnings.join(" ")).toContain("stale local refs/remotes/origin");
} finally {
process.chdir(cwd);
rmSync(dir, { recursive: true, force: true });
}
});
});
describe("fetchGitClaimed — unfetched live claims (G2: ls-remote advertises SHAs without objects)", () => {
// `git ls-remote` lists a branch's tip sha without transferring objects, so
// a branch pushed AFTER the last local fetch has no local object and both
// VERSION reads fail. The old `continue` silently dropped that LIVE claim —
// the exact duplicate-allocation this fallback exists to prevent.
function git(cwd: string, ...args: string[]) {
return Bun.spawnSync(["git", "-c", "user.email=t@t", "-c", "user.name=t", ...args], { cwd });
}
function cloneFixture(): { root: string; origin: string; clone: string } {
const root = mkdtempSync(join(tmpdir(), "nextver-unfetched-"));
const origin = join(root, "origin");
mkdirSync(origin);
git(origin, "init", "-q", "-b", "main");
writeFileSync(join(origin, "VERSION"), "0.1.66.0\n");
git(origin, "add", "-A");
git(origin, "commit", "-qm", "v0.1.66.0 chore: base");
const clone = join(root, "clone");
git(root, "clone", "-q", origin, clone);
return { root, origin, clone };
}
test("a claim branch pushed after the last local fetch is read via a targeted fetch", () => {
const { root, origin, clone } = cloneFixture();
const cwd = process.cwd();
try {
// The claim lands on origin AFTER the clone — its objects are absent
// locally, so `git show <sha>:VERSION` and the remote-tracking read
// both fail until the targeted fetch runs.
git(origin, "checkout", "-q", "-b", "late-claim");
writeFileSync(join(origin, "VERSION"), "0.1.70.0\n");
git(origin, "add", "-A");
git(origin, "commit", "-qm", "v0.1.70.0 feat: late claim");
git(origin, "checkout", "-q", "main");
process.chdir(clone);
const warnings: string[] = [];
const claims = fetchGitClaimed("main", "VERSION", warnings);
expect(claims.map((c) => c.version)).toContain("0.1.70.0");
expect(warnings.join(" ")).not.toContain("UNKNOWN claim");
} finally {
process.chdir(cwd);
rmSync(root, { recursive: true, force: true });
}
});
test("MANY unfetched claim branches resolve with ONE batched fetch, not a per-branch crawl", () => {
// The per-branch fetch loop this replaces ground a shallow CI clone
// against a busy remote for minutes (dozens of sequential network
// fetches). The network budget must stay one round trip no matter how
// many branches are missing — pinned by counting `git fetch` spawns
// through a PATH shim.
const { root, origin, clone } = cloneFixture();
const cwd = process.cwd();
const oldPath = process.env.PATH;
const shimDir = mkdtempSync(join(tmpdir(), "nextver-gitshim-"));
try {
for (const v of ["0.1.70.0", "0.1.71.0", "0.1.72.0"]) {
git(origin, "checkout", "-q", "-b", `late-${v.replace(/\./g, "-")}`);
writeFileSync(join(origin, "VERSION"), `${v}\n`);
git(origin, "add", "-A");
git(origin, "commit", "-qm", `v${v} feat: late claim`);
git(origin, "checkout", "-q", "main");
}
const realGit = Bun.which("git");
const spawnLog = join(shimDir, "spawns.log");
writeFileSync(
join(shimDir, "git"),
`#!/bin/sh\necho "$@" >> "${spawnLog}"\nexec "${realGit}" "$@"\n`,
{ mode: 0o755 },
);
process.chdir(clone);
process.env.PATH = `${shimDir}:${oldPath}`;
const warnings: string[] = [];
const claims = fetchGitClaimed("main", "VERSION", warnings);
process.env.PATH = oldPath;
const versions = claims.map((c) => c.version);
expect(versions).toContain("0.1.70.0");
expect(versions).toContain("0.1.71.0");
expect(versions).toContain("0.1.72.0");
expect(warnings.join(" ")).not.toContain("UNKNOWN claim");
const fetches = readFileSync(spawnLog, "utf-8")
.split("\n")
.filter((l) => l.startsWith("fetch "));
expect(fetches.length).toBe(1);
for (const v of ["0-1-70-0", "0-1-71-0", "0-1-72-0"]) {
expect(fetches[0]).toContain(`refs/heads/late-${v}`);
}
} finally {
process.env.PATH = oldPath;
process.chdir(cwd);
rmSync(root, { recursive: true, force: true });
rmSync(shimDir, { recursive: true, force: true });
}
});
test("one unservable ref does not poison the batch — live claims still resolve, the ghost warns", () => {
// A dangling sha fails the WHOLE batched transfer, so the still-missing
// refs get a bounded per-branch retry: the real claim must come through
// and only the ghost surfaces as UNKNOWN.
const { root, origin, clone } = cloneFixture();
const cwd = process.cwd();
try {
git(origin, "checkout", "-q", "-b", "late-claim");
writeFileSync(join(origin, "VERSION"), "0.1.70.0\n");
git(origin, "add", "-A");
git(origin, "commit", "-qm", "v0.1.70.0 feat: late claim");
git(origin, "checkout", "-q", "main");
writeFileSync(
join(origin, ".git", "refs", "heads", "ghost"),
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n",
);
process.chdir(clone);
const warnings: string[] = [];
const claims = fetchGitClaimed("main", "VERSION", warnings);
expect(claims.map((c) => c.version)).toContain("0.1.70.0");
const joined = warnings.join(" ");
expect(joined).toContain("origin/ghost");
expect(joined).toContain("UNKNOWN claim");
expect(joined).not.toContain("late-claim");
} finally {
process.chdir(cwd);
rmSync(root, { recursive: true, force: true });
}
});
test("a claim STILL unreadable after the fetch surfaces as an UNKNOWN-claim warning, never silence", () => {
const { root, origin, clone } = cloneFixture();
const cwd = process.cwd();
try {
// A ref origin advertises but cannot serve: dangling sha written
// straight into refs/. ls-remote lists it; every local read fails, the
// targeted fetch fails ("not our ref"), and the object never appears.
writeFileSync(
join(origin, ".git", "refs", "heads", "ghost"),
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n",
);
process.chdir(clone);
const warnings: string[] = [];
const claims = fetchGitClaimed("main", "VERSION", warnings);
expect(claims.map((c) => c.branch)).not.toContain("origin/ghost");
const joined = warnings.join(" ");
expect(joined).toContain("origin/ghost");
expect(joined).toContain("UNKNOWN claim");
} finally {
process.chdir(cwd);
rmSync(root, { recursive: true, force: true });
}
});
test("a live branch that simply carries no VERSION file is not a claim and not an UNKNOWN warning", () => {
const { root, origin, clone } = cloneFixture();
const cwd = process.cwd();
try {
// Branch exists BEFORE the clone (objects local), VERSION deleted on it:
// the read fails because the PATH is absent, not the object. Old
// semantics (skip quietly) must hold — no phantom UNKNOWN noise.
git(origin, "checkout", "-q", "-b", "docs-only");
git(origin, "rm", "-q", "VERSION");
git(origin, "commit", "-qm", "docs: no version file");
git(origin, "checkout", "-q", "main");
git(clone, "fetch", "-q", "origin");
process.chdir(clone);
const warnings: string[] = [];
const claims = fetchGitClaimed("main", "VERSION", warnings);
expect(claims.map((c) => c.branch)).not.toContain("origin/docs-only");
expect(warnings.join(" ")).not.toContain("docs-only");
} finally {
process.chdir(cwd);
rmSync(root, { recursive: true, force: true });
}
});
});
describe("width pinned on failed base read (3-digit repos)", () => {
// readBaseVersion used to return a literal "0.0.0.0" when origin/<base> was
// unreadable — a 4-digit string, which flipped versionWidth() to 4 and
// handed a 3-digit repo a 4-digit slot its tooling can't read back (#2501's
// width class, resurfacing through the failure path). The zero base is now
// shaped by the LOCAL version file's width.
const SCRIPT = join(import.meta.dir, "..", "bin", "gstack-next-version");
test("a 3-digit repo keeps 3-digit allocation when origin/<base> is unreadable", () => {
const dir = mkdtempSync(join(tmpdir(), "nextver-width3-"));
const stubDir = mkdtempSync(join(tmpdir(), "nextver-width3-stub-"));
try {
// gh/glab stubs fail → host unknown → git fallback; no origin remote →
// the base read fails too, which is the path under test.
writeFileSync(join(stubDir, "gh"), "#!/bin/sh\nexit 1\n", { mode: 0o755 });
writeFileSync(join(stubDir, "glab"), "#!/bin/sh\nexit 1\n", { mode: 0o755 });
Bun.spawnSync(["git", "init", "-q", "-b", "main"], { cwd: dir });
writeFileSync(join(dir, "VERSION"), "0.99.2\n");
Bun.spawnSync(["git", "-c", "user.email=t@t", "-c", "user.name=t", "add", "-A"], { cwd: dir });
Bun.spawnSync(["git", "-c", "user.email=t@t", "-c", "user.name=t", "commit", "-qm", "init"], { cwd: dir });
const proc = Bun.spawnSync(
["bun", "run", SCRIPT, "--base", "main", "--bump", "patch", "--workspace-root", "null"],
{ cwd: dir, env: { ...process.env, PATH: `${stubDir}:${process.env.PATH}` } },
);
const out = JSON.parse(new TextDecoder().decode(proc.stdout));
// Zero base at the repo's OWN width — never "0.0.0.0" in a 3-digit repo.
expect(out.base_version).toBe("0.0.0");
expect(out.version).toBe("0.0.1"); // 3-digit allocation, not 0.0.1.0
expect(out.warnings.join(" ")).not.toContain("0.0.0.0");
} finally {
rmSync(dir, { recursive: true, force: true });
rmSync(stubDir, { recursive: true, force: true });
}
}, 30_000);
});
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.
+137 -2
View File
@@ -53,6 +53,18 @@ function runWithStdin(input: string, ...args: string[]): { stdout: string; stder
};
}
/** Plant a pref by writing the file. Used for legacy / inert one-way prefs
* that --write now refuses (#2488). --check and --stats still have to
* handle files written before the reject landed. */
function plantPref(id: string, pref: string) {
run('--read');
const projects = fs.readdirSync(path.join(tmpHome, 'projects'));
const file = path.join(tmpHome, 'projects', projects[0], 'question-preferences.json');
const prefs = JSON.parse(fs.readFileSync(file, 'utf-8'));
prefs[id] = pref;
fs.writeFileSync(file, JSON.stringify(prefs, null, 2));
}
// -----------------------------------------------------------------------
// --check
// -----------------------------------------------------------------------
@@ -92,7 +104,8 @@ describe('--check with preferences set', () => {
});
test('one-way + never-ask → ASK_NORMALLY with safety note', () => {
setPref('ship-test-failure-triage', 'never-ask');
// Planted: --write now refuses never-ask on one-way ids (#2488).
plantPref('ship-test-failure-triage', 'never-ask');
const r = run('--check', 'ship-test-failure-triage');
expect(r.stdout).toContain('ASK_NORMALLY');
expect(r.stdout).toContain('one-way door overrides');
@@ -111,7 +124,7 @@ describe('--check with preferences set', () => {
});
test('one-way + ask-only-for-one-way → ASK_NORMALLY', () => {
setPref('ship-test-failure-triage', 'ask-only-for-one-way');
plantPref('ship-test-failure-triage', 'ask-only-for-one-way');
const r = run('--check', 'ship-test-failure-triage');
expect(r.stdout.trim()).toContain('ASK_NORMALLY');
});
@@ -389,6 +402,112 @@ describe('--write schema validation', () => {
});
});
// #2488: --write must refuse suppressing prefs on one-way ids. --check
// already ignores them; storing them made --stats report a working NEVER_ASK.
describe('--write one-way door reject (#2488)', () => {
function prefsFile(): string {
const projects = fs.readdirSync(path.join(tmpHome, 'projects'));
return path.join(tmpHome, 'projects', projects[0], 'question-preferences.json');
}
test('never-ask on one-way id is rejected and does not write', () => {
const r = run(
'--write',
JSON.stringify({
question_id: 'plan-eng-review-arch-finding',
preference: 'never-ask',
source: 'plan-tune',
}),
);
expect(r.status).toBe(1);
expect(r.stderr).toContain('cannot set never-ask');
expect(r.stderr).toContain('plan-eng-review-arch-finding');
expect(r.stderr).toContain('door_type: one-way');
expect(JSON.parse(fs.readFileSync(prefsFile(), 'utf-8'))).toEqual({});
});
test('ask-only-for-one-way on one-way id is rejected and does not write', () => {
const r = run(
'--write',
JSON.stringify({
question_id: 'ship-test-failure-triage',
preference: 'ask-only-for-one-way',
source: 'plan-tune',
}),
);
expect(r.status).toBe(1);
expect(r.stderr).toContain('cannot set ask-only-for-one-way');
expect(r.stderr).toContain('door_type: one-way');
expect(JSON.parse(fs.readFileSync(prefsFile(), 'utf-8'))).toEqual({});
});
test('always-ask on one-way id is accepted (agrees with the safety override)', () => {
const r = run(
'--write',
JSON.stringify({
question_id: 'plan-eng-review-arch-finding',
preference: 'always-ask',
source: 'plan-tune',
}),
);
expect(r.status).toBe(0);
expect(r.stdout).toContain('OK');
expect(JSON.parse(fs.readFileSync(prefsFile(), 'utf-8'))).toEqual({
'plan-eng-review-arch-finding': 'always-ask',
});
});
test('never-ask on two-way id is still accepted', () => {
const r = run(
'--write',
JSON.stringify({
question_id: 'ship-changelog-voice-polish',
preference: 'never-ask',
source: 'plan-tune',
}),
);
expect(r.status).toBe(0);
expect(r.stdout).toContain('OK');
});
test('rejected one-way write does not clobber an existing two-way pref', () => {
run(
'--write',
JSON.stringify({
question_id: 'ship-changelog-voice-polish',
preference: 'never-ask',
source: 'plan-tune',
}),
);
const r = run(
'--write',
JSON.stringify({
question_id: 'plan-eng-review-arch-finding',
preference: 'never-ask',
source: 'plan-tune',
}),
);
expect(r.status).toBe(1);
expect(JSON.parse(fs.readFileSync(prefsFile(), 'utf-8'))).toEqual({
'ship-changelog-voice-polish': 'never-ask',
});
});
test('poisoning source on a one-way id still exits 2 (origin gate first)', () => {
const r = run(
'--write',
JSON.stringify({
question_id: 'plan-eng-review-arch-finding',
preference: 'never-ask',
source: 'inline-tool-output',
}),
);
expect(r.status).toBe(2);
expect(r.stderr).toContain('profile poisoning defense');
expect(r.stderr).not.toContain('door_type');
});
});
// -----------------------------------------------------------------------
// --read, --clear, --stats
// -----------------------------------------------------------------------
@@ -448,5 +567,21 @@ describe('--stats', () => {
expect(r.stdout).toContain('TOTAL: 3');
expect(r.stdout).toContain('NEVER_ASK: 2');
expect(r.stdout).toContain('ALWAYS_ASK: 1');
expect(r.stdout).toContain('INERT_ONE_WAY: 0');
});
test('planted one-way never-ask is INERT_ONE_WAY, not a working NEVER_ASK (#2488)', () => {
plantPref('plan-eng-review-arch-finding', 'never-ask');
const r = run('--stats');
expect(r.stdout).toContain('TOTAL: 1');
expect(r.stdout).toContain('NEVER_ASK: 0');
expect(r.stdout).toContain('INERT_ONE_WAY: 1');
});
test('planted one-way ask-only-for-one-way is INERT_ONE_WAY, not ASK_ONLY_ONE_WAY', () => {
plantPref('ship-test-failure-triage', 'ask-only-for-one-way');
const r = run('--stats');
expect(r.stdout).toContain('ASK_ONLY_ONE_WAY: 0');
expect(r.stdout).toContain('INERT_ONE_WAY: 1');
});
});
+55 -4
View File
@@ -9,6 +9,13 @@ import * as os from "os";
const BIN = path.resolve(import.meta.dir, "..", "bin", "gstack-redact");
// A synthetic AWS access key for feeding the scanner. Derived by
// concatenation so the contiguous credential-shaped literal never appears in
// this file's source — the CI quality gate scans every ADDED diff line with
// this same engine, and a raw fixture literal here fails the gate it exists
// to test (#2610 port fallout). The scanner still sees the assembled bytes.
const FAKE_AWS_KEY = ["AKIA", "1234567890ABCDEF"].join("");
function run(
args: string[],
stdin: string,
@@ -28,7 +35,7 @@ describe("gstack-redact exit codes", () => {
expect(run([], "just some prose").code).toBe(0);
});
test("HIGH → 3", () => {
expect(run([], "key AKIA1234567890ABCDEF").code).toBe(3);
expect(run([], `key ${FAKE_AWS_KEY}`).code).toBe(3);
});
test("MEDIUM only → 2", () => {
expect(run(["--repo-visibility", "public"], "mail bob@corp.io").code).toBe(2);
@@ -37,7 +44,7 @@ describe("gstack-redact exit codes", () => {
describe("gstack-redact --json", () => {
test("emits valid JSON with findings + counts", () => {
const { stdout, code } = run(["--json"], "key AKIA1234567890ABCDEF");
const { stdout, code } = run(["--json"], `key ${FAKE_AWS_KEY}`);
expect(code).toBe(3);
const parsed = JSON.parse(stdout);
expect(parsed.findings[0].id).toBe("aws.access_key");
@@ -59,8 +66,8 @@ describe("gstack-redact --allowlist", () => {
test("allowlisted span is suppressed", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "redact-allow-"));
const allow = path.join(dir, "allow.txt");
fs.writeFileSync(allow, "AKIA1234567890ABCDEF\n");
const { code } = run(["--allowlist", allow], "key AKIA1234567890ABCDEF");
fs.writeFileSync(allow, FAKE_AWS_KEY + "\n");
const { code } = run(["--allowlist", allow], `key ${FAKE_AWS_KEY}`);
expect(code).toBe(0);
fs.rmSync(dir, { recursive: true, force: true });
});
@@ -95,3 +102,47 @@ describe("gstack-redact oversize fails closed", () => {
expect(stdout).toContain("too large");
});
});
describe("gstack-redact argv dispatch", () => {
// The bug: main() recognised exactly two subcommands and let everything else
// fall through to the stdin scan, which reports "no findings" and exits 0.
// So `install-prepush-hooks` (plural typo) installed no hook and still looked
// like success — the credential guard silently absent while the operator
// believes it is armed. A guard that no-ops must never exit 0.
test("a typo'd install subcommand fails loudly instead of exiting 0", () => {
const { code, stderr } = run(["install-prepush-hooks"], "");
expect(code).not.toBe(0);
expect(stderr).toContain("unknown subcommand");
});
test("an unknown positional never reports a clean scan", () => {
const { code, stdout } = run(["totally-bogus"], "");
expect(code).not.toBe(0);
expect(stdout).not.toContain("HIGH=0");
});
// Usage errors must not collide with the findings codes (2 = MEDIUM,
// 3 = HIGH); a caller gating on those would read a typo as "findings".
test("usage errors exit 1, not a findings code", () => {
expect(run(["totally-bogus"], "").code).toBe(1);
});
test("--help prints usage and exits 0 without scanning", () => {
const { code, stdout } = run(["--help"], `key ${FAKE_AWS_KEY}`);
expect(code).toBe(0);
expect(stdout).toContain("STDIN");
expect(stdout).not.toContain("HIGH=1");
});
// "scan" is what the human output header ("gstack-redact scan — repo …")
// invites people to type, so it stays an accepted alias for the default
// filter mode. Rejecting it would break that muscle memory for no gain.
test("the 'scan' alias still scans normally", () => {
expect(run(["scan"], `key ${FAKE_AWS_KEY}`).code).toBe(3);
expect(run(["scan"], "just prose").code).toBe(0);
});
test("flags are still parsed, not mistaken for subcommands", () => {
expect(run(["--json"], "just prose").code).toBe(0);
});
});
@@ -219,6 +219,77 @@ describe('add-event', () => {
});
});
// ----------------------------------------------------------------------
// ensure-event: duplicate (event, source) collapse
// ----------------------------------------------------------------------
describe('ensure-event collapses duplicate (event, source) entries', () => {
test('two same-source entries from the old matcher-keyed dedup collapse to ONE updated entry', () => {
// Pre-existing installs can carry two entries with the same
// (event, _gstack_source) — the old dedup keyed on the matcher too, so a
// matcher change pushed a second registration. `.find()` updated only the
// first and left the stale twin running forever.
const { spawnSync } = require('child_process');
fs.writeFileSync(settingsFile, JSON.stringify({
hooks: {
PostToolUse: [
{ _gstack_source: 'plan-tune-cathedral', matcher: 'OldMatcherA', hooks: [{ type: 'command', command: '/old-a', timeout: 5 }] },
{ matcher: 'Bash', hooks: [{ type: 'command', command: '/user-own-hook' }] },
{ _gstack_source: 'plan-tune-cathedral', matcher: 'OldMatcherB', hooks: [{ type: 'command', command: '/old-b', timeout: 5 }] },
],
},
}, null, 2));
const r = spawnSync('bash', [
SETTINGS_HOOK, 'ensure-event',
'--event', 'PostToolUse',
'--matcher', 'NewMatcher',
'--command', '/canonical',
'--source', 'plan-tune-cathedral',
'--timeout', '5',
], { env: { ...process.env, GSTACK_SETTINGS_FILE: settingsFile }, encoding: 'utf-8', timeout: 15_000 });
expect(r.status).toBe(0);
// The collapse is reported on stderr, never silent.
expect(r.stderr).toContain('collapsed 1 duplicate');
const s = settings();
const mine = s.hooks.PostToolUse.filter((e: any) => e._gstack_source === 'plan-tune-cathedral');
expect(mine).toHaveLength(1); // ONE canonical entry — the stale twin is gone
expect(mine[0].matcher).toBe('NewMatcher');
expect(mine[0].hooks[0].command).toBe('/canonical');
// Unrelated user hook untouched.
const bash = s.hooks.PostToolUse.find((e: any) => e.matcher === 'Bash');
expect(bash.hooks[0].command).toBe('/user-own-hook');
expect(s.hooks.PostToolUse).toHaveLength(2);
});
test('no duplicates → no collapse message, single entry updated as before', () => {
const { spawnSync } = require('child_process');
fs.writeFileSync(settingsFile, JSON.stringify({
hooks: {
PostToolUse: [
{ _gstack_source: 'plan-tune-cathedral', matcher: 'OldMatcher', hooks: [{ type: 'command', command: '/old', timeout: 5 }] },
],
},
}, null, 2));
const r = spawnSync('bash', [
SETTINGS_HOOK, 'ensure-event',
'--event', 'PostToolUse',
'--matcher', 'NewMatcher',
'--command', '/new',
'--source', 'plan-tune-cathedral',
'--timeout', '5',
], { env: { ...process.env, GSTACK_SETTINGS_FILE: settingsFile }, encoding: 'utf-8', timeout: 15_000 });
expect(r.status).toBe(0);
expect(r.stderr).not.toContain('collapsed');
const s = settings();
expect(s.hooks.PostToolUse).toHaveLength(1);
expect(s.hooks.PostToolUse[0].hooks[0].command).toBe('/new');
});
});
// ----------------------------------------------------------------------
// remove-source
// ----------------------------------------------------------------------
+272
View File
@@ -0,0 +1,272 @@
/**
* bin/gstack-slug browse/bin/remote-slug parity.
*
* The bug this pins (2026-08-17, observed live in a Conductor worktree of
* garrytan/gstack): a stray marker-bearing ancestor above the repo an empty
* `~/.git` directory that is not even a valid git repo captured
* gstack-slug's "outermost strong marker" walk-up as the project root. That
* ancestor has no `origin` remote, so the resolver silently degraded to
* `basename($HOME)` and emitted `SLUG=garrytan`, while remote-slug (which
* asks git for the containing repo's remote) correctly said
* `garrytan-gstack`. Every store keyed on the slug (decisions, timeline,
* ceo-plans, learnings) filed into ~/.gstack/projects/garrytan/ one bucket
* shared by every repo under $HOME.
*
* The fix makes the canonical remote authoritative: gstack-slug now walks the
* ancestor chain for the OUTERMOST dir with a `.git` entry (dir for normal
* clones, FILE for git-worktrees) whose `origin` remote resolves, and derives
* `owner-repo` with the exact same parse remote-slug uses. Marker-only
* ancestors that are not remote-bearing repos can still anchor the basename
* FALLBACK, but they can no longer shadow a real remote.
*
* Contracts pinned here:
* - Parity: for any repo (plain clone or git-worktree) whose slug derivation
* reaches a canonical remote, gstack-slug's SLUG equals remote-slug's
* output including under a stray-marker home.
* - Walk-up preserved: a nested inner repo under an outer canonical-remote
* repo resolves to the OUTER repo's owner-repo (outermost wins), matching
* remote-slug run at the outer root.
* - Fallback preserved: a no-remote repo still resolves to its basename.
* - Cache self-heal: a pre-fix degraded cache entry (== the bogus marker
* root's basename) is rewritten to the canonical slug; legit #2212 sticky
* identity (repo that adopted a remote after first use) is NOT healed.
*
* Test pattern mirrors test/gstack-slug-cwd-walk-up.test.ts: per-test
* tmpHome, spawnSync against the real bash scripts, fixtures on disk.
*/
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import { spawnSync, type SpawnSyncReturns } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
const ROOT = path.resolve(import.meta.dir, '..');
const SLUG_SCRIPT = path.join(ROOT, 'bin', 'gstack-slug');
const REMOTE_SLUG_SCRIPT = path.join(ROOT, 'browse', 'bin', 'remote-slug');
function baseEnv(tmpHome: string): Record<string, string | undefined> {
// Drop any ambient override: a sibling test leaking GSTACK_PROJECT_SLUG in
// a shared-process shard would flip runs into override mode.
const { GSTACK_PROJECT_SLUG: _drop, ...ambient } = process.env;
return { ...ambient, HOME: tmpHome, GSTACK_HOME: path.join(tmpHome, '.gstack') };
}
function runSlug(cwd: string, tmpHome: string): SpawnSyncReturns<string> {
return spawnSync('bash', [SLUG_SCRIPT], {
cwd,
env: baseEnv(tmpHome),
encoding: 'utf8',
timeout: 10_000,
});
}
function runRemoteSlug(cwd: string, tmpHome: string): SpawnSyncReturns<string> {
return spawnSync('bash', [REMOTE_SLUG_SCRIPT], {
cwd,
env: baseEnv(tmpHome),
encoding: 'utf8',
timeout: 10_000,
});
}
function slugOf(r: SpawnSyncReturns<string>): string {
const m = r.stdout.match(/^SLUG=([^\n]*)$/m);
return m ? m[1]! : '';
}
function git(args: string[], opts: { cwd?: string } = {}): void {
const r = spawnSync('git', args, { encoding: 'utf8', timeout: 10_000, ...opts });
if (r.status !== 0) {
throw new Error(`git ${args.join(' ')} failed: ${r.stderr}`);
}
}
/** git init -b main + optional origin remote. Returns the repo path. */
function makeRepo(dir: string, originUrl?: string): string {
fs.mkdirSync(dir, { recursive: true });
git(['init', '-q', '-b', 'main', dir]);
if (originUrl) git(['-C', dir, 'remote', 'add', 'origin', originUrl]);
return dir;
}
function encodedCacheKey(absPath: string): string {
return absPath.replace(/\//g, '_');
}
/** Assert both scripts succeed in `cwd` and emit the same slug. */
function expectParity(cwd: string, tmpHome: string, expected: string): void {
const gstack = runSlug(cwd, tmpHome);
const remote = runRemoteSlug(cwd, tmpHome);
expect(gstack.status).toBe(0);
expect(remote.status).toBe(0);
const remoteOut = remote.stdout.trim();
expect(slugOf(gstack)).toBe(expected);
expect(remoteOut).toBe(expected);
expect(slugOf(gstack)).toBe(remoteOut);
}
describe('gstack-slug ↔ remote-slug parity', () => {
let tmpHome: string;
let fixtures: string;
beforeEach(() => {
// realpathSync: macOS tmpdir is a symlink (/var -> /private/var); the
// scripts key their cache and walk on the resolved cwd.
tmpHome = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'slug-parity-home-')));
fixtures = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'slug-parity-fix-')));
});
afterEach(() => {
try { fs.rmSync(tmpHome, { recursive: true, force: true }); } catch {}
try { fs.rmSync(fixtures, { recursive: true, force: true }); } catch {}
});
test('plain clone, https remote WITH .git suffix — identical owner-repo slug', () => {
const repo = makeRepo(path.join(fixtures, 'proj'), 'https://github.com/acme/widgets.git');
expectParity(repo, tmpHome, 'acme-widgets');
});
test('plain clone, https remote WITHOUT .git suffix (live-bug URL shape) — identical slug', () => {
const repo = makeRepo(path.join(fixtures, 'proj'), 'https://github.com/garrytan/gstack');
expectParity(repo, tmpHome, 'garrytan-gstack');
});
test('plain clone, scp-like ssh remote — identical owner-repo slug', () => {
const repo = makeRepo(path.join(fixtures, 'proj'), 'git@github.com:acme/widgets.git');
expectParity(repo, tmpHome, 'acme-widgets');
});
test('git-worktree of a clone (.git FILE, the Conductor shape) — identical slug', () => {
const main = makeRepo(path.join(fixtures, 'main-clone'), 'https://github.com/garrytan/gstack');
git(['-C', main, '-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-q', '--allow-empty', '-m', 'init']);
const wt = path.join(fixtures, 'wt');
git(['-C', main, 'worktree', 'add', '-q', wt, '-b', 'feature-branch']);
// Sanity: worktree roots carry a .git FILE, not a directory.
expect(fs.statSync(path.join(wt, '.git')).isFile()).toBe(true);
expectParity(wt, tmpHome, 'garrytan-gstack');
});
test('LIVE BUG SHAPE: stray empty .git on an ancestor "home" no longer degrades the slug', () => {
// The exact 2026-08-17 reproduction: an ancestor dir with an empty .git
// (not a valid repo, no origin) above a canonical-remote worktree.
const strayHome = path.join(fixtures, 'strayhome');
fs.mkdirSync(path.join(strayHome, '.git'), { recursive: true }); // empty — invalid repo
const main = makeRepo(
path.join(strayHome, 'conductor', 'workspaces', 'gstack', 'main-clone'),
'https://github.com/garrytan/gstack',
);
git(['-C', main, '-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-q', '--allow-empty', '-m', 'init']);
const wt = path.join(strayHome, 'conductor', 'workspaces', 'gstack', 'beirut-v4');
git(['-C', main, 'worktree', 'add', '-q', wt, '-b', 'gstack-fix-wave']);
// Both the plain clone and the worktree must resolve to owner-repo — the
// pre-fix resolver emitted `strayhome` (the marker root's basename) here.
expectParity(main, tmpHome, 'garrytan-gstack');
expectParity(wt, tmpHome, 'garrytan-gstack');
expect(slugOf(runSlug(wt, tmpHome))).not.toBe('strayhome');
});
test('walk-up preserved: nested inner repo (no remote) resolves to the OUTER repo slug', () => {
const outer = makeRepo(path.join(fixtures, 'outer'), 'git@github.com:acme/outer.git');
const inner = makeRepo(path.join(outer, 'vendor', 'inner'));
const gstack = runSlug(inner, tmpHome);
expect(gstack.status).toBe(0);
// Outermost remote-bearing repo wins — same answer as remote-slug asked
// at the outer root. (remote-slug asked from INSIDE the inner repo can't
// see past the inner .git — its remote derivation does not succeed there,
// so the parity clause doesn't apply; the walk-up contract does.)
expect(slugOf(gstack)).toBe('acme-outer');
expect(runRemoteSlug(outer, tmpHome).stdout.trim()).toBe('acme-outer');
});
test('walk-up preserved: nested inner repo WITH its own remote still resolves to the OUTER repo slug', () => {
const outer = makeRepo(path.join(fixtures, 'outer'), 'git@github.com:acme/outer.git');
const inner = makeRepo(path.join(outer, 'vendor', 'inner'), 'git@github.com:acme/inner.git');
const gstack = runSlug(inner, tmpHome);
expect(gstack.status).toBe(0);
// Outermost wins — unchanged from the pre-fix walk-up semantics.
expect(slugOf(gstack)).toBe('acme-outer');
});
test('fallback unchanged: no-remote repo resolves to its basename (and remote-slug agrees)', () => {
const repo = makeRepo(path.join(fixtures, 'lonely'));
const gstack = runSlug(repo, tmpHome);
expect(gstack.status).toBe(0);
expect(slugOf(gstack)).toBe('lonely');
// remote-slug's own no-remote fallback is basename(toplevel) — parity
// holds incidentally on this shape too.
expect(runRemoteSlug(repo, tmpHome).stdout.trim()).toBe('lonely');
});
test('cache self-heal: a pre-fix degraded cache entry is rewritten to the canonical slug', () => {
const strayHome = path.join(fixtures, 'strayhome');
fs.mkdirSync(path.join(strayHome, '.git'), { recursive: true });
const repo = makeRepo(path.join(strayHome, 'git', 'proj'), 'https://github.com/garrytan/gstack');
// Pre-seed the cache with the pre-fix degraded value: the bogus marker
// root's basename (what the old resolver computed and cached).
const cacheDir = path.join(tmpHome, '.gstack', 'slug-cache');
fs.mkdirSync(cacheDir, { recursive: true });
const cacheFile = path.join(cacheDir, encodedCacheKey(repo));
fs.writeFileSync(cacheFile, 'strayhome');
const gstack = runSlug(repo, tmpHome);
expect(gstack.status).toBe(0);
expect(slugOf(gstack)).toBe('garrytan-gstack');
// The cache file itself must have been overwritten (self-healing).
expect(fs.readFileSync(cacheFile, 'utf8').trim()).toBe('garrytan-gstack');
});
test('hostile origin `url = ..` cannot become a ".." slug — basename fallback holds', () => {
// git accepts `..` as a remote URL; the sed parse passes it through
// unchanged, so unguarded it becomes SLUG=".." — filing state one level
// ABOVE ~/.gstack/projects/ (confined to ~/.gstack, but still traversal).
// The dot-only guard rejects it and the basename fallback anchors identity.
const repo = makeRepo(path.join(fixtures, 'dotty'), '..');
const r = runSlug(repo, tmpHome);
expect(r.status).toBe(0);
expect(slugOf(r)).toBe('dotty');
// The cache must hold the healed value, never the dot slug.
const cacheFile = path.join(tmpHome, '.gstack', 'slug-cache', encodedCacheKey(repo));
expect(fs.readFileSync(cacheFile, 'utf8').trim()).toBe('dotty');
});
test('package.json wrapper root (no .git): sticky basename slug is PRESERVED — heal is stray-repo-shape only', () => {
// Legit #2212 shape: a monorepo wrapper anchored by package.json used
// gstack before an inner dir grew a remote-bearing repo. The degraded-
// ancestor heal must NOT fire here — it is restricted to marker roots
// anchored by a .git entry whose origin does NOT resolve (the live-bug
// stray-repo shape).
const wrapper = path.join(fixtures, 'wrapperproj');
fs.mkdirSync(wrapper, { recursive: true });
fs.writeFileSync(path.join(wrapper, 'package.json'), '{"name":"wrapper"}\n');
const inner = makeRepo(path.join(wrapper, 'apps', 'web'), 'https://github.com/acme/web.git');
const cacheDir = path.join(tmpHome, '.gstack', 'slug-cache');
fs.mkdirSync(cacheDir, { recursive: true });
const cacheFile = path.join(cacheDir, encodedCacheKey(inner));
fs.writeFileSync(cacheFile, 'wrapperproj');
const r = runSlug(inner, tmpHome);
expect(r.status).toBe(0);
expect(slugOf(r)).toBe('wrapperproj'); // NOT healed to acme-web
expect(fs.readFileSync(cacheFile, 'utf8').trim()).toBe('wrapperproj');
});
test('sticky identity preserved (#2212): repo that adopted a remote after first use is NOT healed', () => {
// Legit sticky shape: the repo itself is the marker root (REMOTE_ROOT ==
// PROJECT_ROOT) and its cached identity is its pre-origin basename slug.
const repo = makeRepo(path.join(fixtures, 'stickyproj'), 'https://github.com/x/y.git');
const cacheDir = path.join(tmpHome, '.gstack', 'slug-cache');
fs.mkdirSync(cacheDir, { recursive: true });
const cacheFile = path.join(cacheDir, encodedCacheKey(repo));
fs.writeFileSync(cacheFile, 'stickyproj');
const gstack = runSlug(repo, tmpHome);
expect(gstack.status).toBe(0);
expect(slugOf(gstack)).toBe('stickyproj');
expect(fs.readFileSync(cacheFile, 'utf8').trim()).toBe('stickyproj');
});
});
+179
View File
@@ -577,3 +577,182 @@ describe('path containment: pins and flags cannot escape the repo', () => {
expect(JSON.parse(fs.readFileSync(path.join(dir, 'frontend', 'package.json'), 'utf-8')).version).toBe('1.1.0');
});
});
describe('#2600: repair must not write fabricated 0.0.0.0 when VERSION is missing', () => {
// Per-test dirs: the tests assert both "VERSION absent" and "VERSION
// present" states, so a shared dir made them order-dependent (test 1's
// absence assertion only held because test 2 hadn't run yet).
const dirs: string[] = [];
const makeDir = (): string => {
const d = fs.mkdtempSync(path.join(os.tmpdir(), 'vbump-2600-'));
dirs.push(d);
return d;
};
afterAll(() => {
for (const d of dirs) { try { fs.rmSync(d, { recursive: true, force: true }); } catch { /* noop */ } }
});
test('repair fails with exit 2 when VERSION file does not exist', () => {
const dir = makeDir();
// Set up: package.json exists with version 0.1.0.0, but no VERSION file
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'x', version: '0.1.0.0' }, null, 2) + '\n');
// VERSION file deliberately absent
expect(fs.existsSync(path.join(dir, 'VERSION'))).toBe(false);
let code = 0;
let stderr = '';
try {
execFileSync('bun', [BIN, 'repair'], { cwd: dir, stdio: 'pipe' });
} catch (e: any) {
code = e.status;
stderr = (e.stderr || '').toString();
}
// Should fail, not succeed
expect(code).toBe(2);
expect(stderr).toContain('VERSION file not found');
// package.json must NOT be modified
expect(JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf-8')).version).toBe('0.1.0.0');
});
test('repair works normally when VERSION file exists', () => {
const dir = makeDir();
// Set up: both VERSION and package.json exist, with drift
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');
const out = execFileSync('bun', [BIN, 'repair'], { cwd: dir }).toString();
const result = JSON.parse(out);
expect(result.repaired).toBe('2.0.0.0');
expect(result.packageJsonVersion).toBe('2.0.0');
expect(JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf-8')).version).toBe('2.0.0');
});
test('repair refuses to propagate a fabricated version when VERSION file is empty (#2600)', () => {
const dir = makeDir();
// VERSION exists but is empty — readVersionFile folds this into DEFAULT ("0.0.0.0").
// Without the `current === DEFAULT` guard, this would write 0.0.0 into package.json.
fs.writeFileSync(path.join(dir, 'VERSION'), '');
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'x', version: '0.5.0' }, null, 2) + '\n');
let code = 0;
let stderr = '';
try {
execFileSync('bun', [BIN, 'repair'], { cwd: dir, stdio: 'pipe' });
} catch (e: any) {
code = e.status;
stderr = (e.stderr || '').toString();
}
expect(code).toBe(2);
expect(stderr).toContain('empty or contains no parsable version');
// package.json must NOT be modified
expect(JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf-8')).version).toBe('0.5.0');
});
test('repair proceeds when VERSION genuinely reads 0.0.0.0 (a real file, not the sentinel)', () => {
// current === DEFAULT is ambiguous: it is BOTH the missing/unparseable
// sentinel AND a legitimate literal "0.0.0.0" in a brand-new repo. The
// guard now disambiguates on the raw bytes — a real 0.0.0.0 repairs
// package.json to the npm-valid 0.0.0.
const dir = makeDir();
fs.writeFileSync(path.join(dir, 'VERSION'), '0.0.0.0\n');
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'x', version: '0.5.0' }, null, 2) + '\n');
const out = execFileSync('bun', [BIN, 'repair'], { cwd: dir }).toString();
const result = JSON.parse(out);
expect(result.repaired).toBe('0.0.0.0');
expect(result.packageJsonVersion).toBe('0.0.0');
expect(JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf-8')).version).toBe('0.0.0');
});
test('repair still rejects whitespace-only VERSION content (sentinel path, not a real version)', () => {
const dir = makeDir();
fs.writeFileSync(path.join(dir, 'VERSION'), ' \n\n');
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'x', version: '0.5.0' }, null, 2) + '\n');
let code = 0;
try { execFileSync('bun', [BIN, 'repair'], { cwd: dir, stdio: 'pipe' }); }
catch (e: any) { code = e.status; }
expect(code).toBe(2);
expect(JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf-8')).version).toBe('0.5.0');
});
test('repair reproduces the exact issue scenario: VERSION in root, package.json in app/ (#2600)', () => {
// The exact layout from the issue: VERSION at repo root, package.json in app/
// Running repair from app/ cwd with no VERSION there used to write 0.0.0.0 into app/package.json.
const rootDir = makeDir();
fs.mkdirSync(path.join(rootDir, 'app'), { recursive: true });
fs.writeFileSync(path.join(rootDir, 'VERSION'), '0.2.0.0\n');
fs.writeFileSync(path.join(rootDir, 'app', 'package.json'), JSON.stringify({ name: 'x', version: '0.1.0.0' }, null, 2) + '\n');
// Run from app/ — no VERSION in cwd, readVersionFile would fold to 0.0.0.0
let code = 0;
let stderr = '';
try {
execFileSync('bun', [BIN, 'repair'], { cwd: path.join(rootDir, 'app'), stdio: 'pipe' });
} catch (e: any) {
code = e.status;
stderr = (e.stderr || '').toString();
}
expect(code).toBe(2);
expect(stderr).toContain('VERSION file not found');
// app/package.json must NOT be modified
expect(JSON.parse(fs.readFileSync(path.join(rootDir, 'app', 'package.json'), 'utf-8')).version).toBe('0.1.0.0');
});
});
describe('#2600: classify must surface versionFileExists=false when VERSION is missing', () => {
// Per-test dirs: one test asserts VERSION absent, the other creates it — a
// shared dir made them order-dependent. Each test builds its own repo.
const dirs: string[] = [];
afterAll(() => {
for (const d of dirs) { try { fs.rmSync(d, { recursive: true, force: true }); } catch { /* noop */ } }
});
/** Minimal git repo (no VERSION committed) so classify can resolve base. */
function makeRepoDir(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vbump-2600-classify-'));
dirs.push(dir);
const git = (...a: string[]) => execFileSync('git', a, { cwd: dir, stdio: 'pipe' });
git('init', '-q', '-b', 'main');
git('config', 'user.email', 't@t'); git('config', 'user.name', 't');
// Commit with no VERSION file
fs.writeFileSync(path.join(dir, 'README.md'), 'test\n');
git('add', '-A'); git('commit', '-q', '-m', 'base');
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');
return dir;
}
test('classify reports versionFileExists=false when VERSION is absent', () => {
const dir = makeRepoDir();
// No package.json: pkgExists=false, pkgAgrees=true, current===base → FRESH.
// (A package.json with a non-zero version would cause DRIFT_UNEXPECTED.)
const out = execFileSync('bun', [BIN, 'classify', '--base', 'main'], { cwd: dir }).toString();
const result = JSON.parse(out);
expect(result.versionFileExists).toBe(false);
expect(result.currentVersion).toBe('0.0.0.0'); // fabricated default
expect(result.state).toBe('FRESH'); // base also reads 0.0.0.0, no pkg drift
});
test('classify reports versionFileExists=true when VERSION is present', () => {
const dir = makeRepoDir();
// Create VERSION AND sync package.json so pkgAgrees=true → ALREADY_BUMPED.
fs.writeFileSync(path.join(dir, 'VERSION'), '0.2.0.0\n');
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'x', version: '0.2.0.0' }, null, 2) + '\n');
const out = execFileSync('bun', [BIN, 'classify', '--base', 'main'], { cwd: dir }).toString();
const result = JSON.parse(out);
expect(result.versionFileExists).toBe(true);
expect(result.currentVersion).toBe('0.2.0.0');
expect(result.state).toBe('ALREADY_BUMPED'); // base is 0.0.0.0, current is 0.2.0.0, pkg in sync
});
});
+9 -9
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_800, // v1.67 wave + v1.66.1's evidence-ledger prose (merged): measured 90,333
maxSkeletonBytes: 91_600, // v1.68 fix wave: unconditional learnings capture (#2402, ~450B/skill); measured 91,061
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: 93_000, // v1.67 fix wave: #2499 jq entry-resolution in the brain-sync preamble (~340B/skill) + wave doc additions; measured 92,531
maxSkeletonBytes: 93_900, // v1.68 fix wave: #2402 learnings capture + spool queue-depth lines; measured 93,345
minUnionBytes: 80_000,
mustContain: ['SCOPE EXPANSION', 'SELECTIVE EXPANSION', 'HOLD SCOPE', 'SCOPE REDUCTION'],
// Default-on Codex outside-voice (codexPreflight block + CODEX_MODE branch
@@ -183,7 +183,7 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
// check grew every plan-review skeleton ~0.7KB. Measured values noted.
// #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
maxSkeletonBytes: 71_800, // v1.68 fix wave (#2402); measured 71,228
minUnionBytes: 70_000,
mustContain: ['Architecture', 'Code Quality', 'Test', 'Performance'],
// Cross-cutting preamble growth (v1.57.2.0 AUQ-failure prose fallback + the
@@ -216,7 +216,7 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
// tier-2+ skeleton (measured 89,184). Main's v1.64.0.0 adds ~340 B more
// (telemetry --error-message/--failed-step preamble prose, PR #769).
// Budget covers the sum of both waves.
maxSkeletonBytes: 91_000,
maxSkeletonBytes: 91_700, // v1.68 fix wave (#2402); measured 91,176
minUnionBytes: 70_000,
mustContain: ['design', 'visual'],
maxSizeRatio: 1.12, // D1 1.104 + main's ~0.008
@@ -240,7 +240,7 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
// check grew every plan-review skeleton ~0.7KB. Measured values noted.
// #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
maxSkeletonBytes: 83_500, // v1.68 fix wave (#2402); measured 82,941
minUnionBytes: 70_000,
mustContain: ['developer experience', 'Getting Started'],
// Default-on Codex outside-voice (codexPreflight block + CODEX_MODE branch
@@ -270,7 +270,7 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
// the #538 opt-out + D1 evidence directive — ratio 1.104 measured.
// #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
maxSkeletonBytes: 102_800, // v1.68 fix wave (#2402); measured 102,220
minUnionBytes: 70_000,
mustContain: ['design doc', 'problem statement'],
maxSizeRatio: 1.12,
@@ -291,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: 57_000, // v1.67 fix wave: #2499 preamble growth; measured 56,571
maxSkeletonBytes: 57_900, // v1.68 fix wave (#2402); measured 57,385
minUnionBytes: 55_000,
mustContain: ['CHANGELOG', 'Diataxis', 'coverage'],
// Two intentional additions stack on this small skill: the AUQ-failure prose
@@ -322,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_500, // v1.67 fix wave: #2499 preamble growth; measured 70,003
maxSkeletonBytes: 71_400, // v1.68 fix wave (#2402); measured 70,815
minUnionBytes: 72_000,
mustContain: ['Typography', 'Color', 'Aesthetic Direction'],
// Cross-cutting preamble growth (v1.57.2.0 AUQ-failure prose fallback ~2KB +
@@ -362,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: 76_400, // v1.67 fix wave: #2499 preamble growth; measured 75,891
maxSkeletonBytes: 77_300, // v1.68 fix wave (#2402); measured 76,705
minUnionBytes: 72_000,
mustContain: ['OWASP', 'STRIDE', 'daily', 'comprehensive', 'verif'],
// cso keeps its mode-dispatch + FP-filtering phases always-loaded, so the
+178
View File
@@ -122,3 +122,181 @@ describe('gstack-session-update pull wedge (#2566)', () => {
}
}, 30000);
});
// ── #2613: the lock pidfile must record the LIVE holder, not the exited parent ──
//
// `echo $$` inside the backgrounded subshell recorded the parent hook's PID.
// The parent exits immediately, so every subsequent session judged the lock
// stale and rm -rf'd a LIVE holder's lock — concurrent updaters, the exact
// state the lock exists to prevent. Plus: a hard TTL (heartbeat-refreshed)
// bounds PID-reuse wedges and the empty/missing-pidfile races.
describe('gstack-session-update lock identity + TTL (#2613)', () => {
function makeSlowGitShim(base: string, sleepSecs: number): string {
const shimDir = path.join(base, 'shim');
fs.mkdirSync(shimDir, { recursive: true });
const realGit = execFileSync('bash', ['-c', 'command -v git'], { encoding: 'utf8' }).trim();
fs.writeFileSync(
path.join(shimDir, 'git'),
`#!/usr/bin/env bash\ncase "$*" in *pull*) sleep ${sleepSecs};; esac\nexec "${realGit}" "$@"\n`,
{ mode: 0o755 },
);
return shimDir;
}
function runScriptWithPath(install: string, state: string, shimDir: string) {
return spawnSync('bash', [SCRIPT], {
encoding: 'utf8',
env: { ...process.env, GSTACK_DIR: install, GSTACK_STATE_DIR: state, PATH: `${shimDir}:${process.env.PATH}` },
timeout: 20000,
});
}
function isAlive(pid: number): boolean {
try { process.kill(pid, 0); return true; } catch { return false; }
}
test('recorded pid is the live holder subshell, not the exited parent', async () => {
const { base, install, state } = makeFixture();
const shimDir = makeSlowGitShim(base, 3);
try {
const r = runScriptWithPath(install, state, shimDir);
expect(r.status).toBe(0); // parent hook has EXITED by now (spawnSync waited)
// Poll for the pidfile the detached subshell writes.
const pidPath = path.join(state, '.setup-lock', 'pid');
const deadline = Date.now() + 5000;
let pid = 0;
while (Date.now() < deadline) {
if (fs.existsSync(pidPath)) {
pid = Number(fs.readFileSync(pidPath, 'utf8').trim());
if (pid > 0) break;
}
await new Promise((res) => setTimeout(res, 50));
}
expect(pid).toBeGreaterThan(0);
// The lock is held (slow pull) — its recorded PID must be ALIVE.
// Pre-fix this held the dead parent's PID and the assertion fails.
expect(fs.existsSync(path.join(state, '.setup-lock'))).toBe(true);
expect(isAlive(pid)).toBe(true);
await waitForLog(state, /UP_TO_DATE|UPDATING|PULL_FAILED/);
} finally {
fs.rmSync(base, { recursive: true, force: true });
}
}, 30000);
test('a live lock with a live pid is respected and survives', async () => {
const { base, install, state } = makeFixture();
const holder = require('child_process').spawn('sleep', ['30'], { stdio: 'ignore' });
try {
const lockDir = path.join(state, '.setup-lock');
fs.mkdirSync(lockDir, { recursive: true });
fs.writeFileSync(path.join(lockDir, 'pid'), String(holder.pid));
const r = runScript(install, state);
expect(r.status).toBe(0);
const log = await waitForLog(state, /SKIP locked_by=/);
expect(log).toContain(`SKIP locked_by=${holder.pid}`);
expect(fs.existsSync(lockDir)).toBe(true); // NOT rm -rf'd (#2613)
} finally {
holder.kill();
fs.rmSync(base, { recursive: true, force: true });
}
}, 30000);
test('a dead pid is reclaimed and the run proceeds', async () => {
const { base, install, state } = makeFixture();
try {
const dead = spawnSync('true', { encoding: 'utf8' }); // reaped by the time spawnSync returns
const lockDir = path.join(state, '.setup-lock');
fs.mkdirSync(lockDir, { recursive: true });
fs.writeFileSync(path.join(lockDir, 'pid'), String(dead.pid));
const r = runScript(install, state);
expect(r.status).toBe(0);
const log = await waitForLog(state, /UP_TO_DATE|UPDATING/);
expect(log).toMatch(/UP_TO_DATE|UPDATING/);
} finally {
fs.rmSync(base, { recursive: true, force: true });
}
}, 30000);
test('an empty pidfile inside the TTL window is NOT instantly reaped', async () => {
const { base, install, state } = makeFixture();
try {
const lockDir = path.join(state, '.setup-lock');
fs.mkdirSync(lockDir, { recursive: true });
fs.writeFileSync(path.join(lockDir, 'pid'), ''); // mkdir→echo race window
const r = runScript(install, state);
expect(r.status).toBe(0);
const log = await waitForLog(state, /SKIP locked_by=/);
expect(log).toContain('SKIP locked_by=');
expect(fs.existsSync(lockDir)).toBe(true);
} finally {
fs.rmSync(base, { recursive: true, force: true });
}
}, 30000);
test('reclaim is TOCTOU-safe: both reclaim branches mv the lock aside atomically (static pin)', () => {
// `rm -rf "$LOCK_DIR"` then `mkdir` lets TWO contenders both judge the
// lock stale and both win (one rm can land between the other's rm and
// mkdir). The atomic mv-aside makes exactly one contender own the reap:
// the loser's mv fails and it backs off with SKIP lock_contested. Pin
// that BOTH reclaim branches (TTL-expired and dead-PID) use it, and that
// no bare in-place `rm -rf "$LOCK_DIR"` survives outside the holder's
// own EXIT trap.
const src = fs.readFileSync(SCRIPT, 'utf8');
const mvAside = src.match(/mv "\$LOCK_DIR" "\$LOCK_DIR\.reap\.\$\$" 2>\/dev\/null \|\| \{ log_entry "SKIP lock_contested"; exit 0; \}/g) || [];
expect(mvAside.length).toBe(2); // TTL branch + dead-PID branch
// The only rm -rf of the live lock dir is the holder's EXIT trap — and
// even that one is ownership-checked (see the static pin below).
const bareRms = src.match(/rm -rf "\$LOCK_DIR"(?!\.)/g) || [];
expect(bareRms.length).toBe(1);
expect(src).toContain(
`trap 'kill "$HB_PID" 2>/dev/null; [ "$(cat "$LOCK_DIR/pid" 2>/dev/null)" = "$MYPID" ] && rm -rf "$LOCK_DIR" 2>/dev/null' EXIT`,
);
});
test('EXIT trap is ownership-checked and a heartbeat runs during pull/setup (static pins)', () => {
const src = fs.readFileSync(SCRIPT, 'utf8');
// (a) After a TTL reclaim by another updater, $LOCK_DIR belongs to the
// NEW holder — the old holder's trap must remove the lock ONLY while the
// pidfile still contains ITS pid (MYPID captured at write time).
const trapLine = src.split('\n').find((l) => l.includes("trap '") && l.includes('rm -rf "$LOCK_DIR"'));
expect(trapLine).toBeDefined();
expect(trapLine!).toContain('[ "$(cat "$LOCK_DIR/pid" 2>/dev/null)" = "$MYPID" ] && rm -rf "$LOCK_DIR"');
// MYPID is written to the pidfile (the identity the trap compares against).
expect(src).toContain('MYPID="${BASHPID:-$(sh -c \'echo $PPID\')}"');
expect(src).toContain('echo "$MYPID" > "$LOCK_DIR/pid"');
// (b) In-flight heartbeat: the step-boundary touches only fire AFTER the
// pull / setup return, so a legitimately-slow step past the 30-min TTL
// got reclaimed while ALIVE. The loop re-checks ownership each beat and
// exits instead of touching a reclaimed holder's pidfile.
expect(src).toMatch(
/while :; do sleep 300; \[ "\$\(cat "\$LOCK_DIR\/pid" 2>\/dev\/null\)" = "\$MYPID" \] \|\| exit 0; touch "\$LOCK_DIR\/pid" 2>\/dev\/null; done/,
);
expect(src).toContain('HB_PID=$!');
// The trap stops the heartbeat so it can never outlive the holder.
expect(trapLine!).toContain('kill "$HB_PID"');
});
test('an expired-TTL lock is reclaimed even when its pid is alive (PID reuse)', async () => {
const { base, install, state } = makeFixture();
const holder = require('child_process').spawn('sleep', ['30'], { stdio: 'ignore' });
try {
const lockDir = path.join(state, '.setup-lock');
fs.mkdirSync(lockDir, { recursive: true });
const pidPath = path.join(lockDir, 'pid');
fs.writeFileSync(pidPath, String(holder.pid));
// Age the heartbeat past the 30-min TTL: a recycled PID looks alive
// forever, so liveness alone can never clear this wedge.
const past = new Date(Date.now() - 40 * 60 * 1000);
fs.utimesSync(pidPath, past, past);
const r = runScript(install, state);
expect(r.status).toBe(0);
const log = await waitForLog(state, /RECLAIMED lock_ttl_expired/);
expect(log).toContain('RECLAIMED lock_ttl_expired');
await waitForLog(state, /UP_TO_DATE|UPDATING/);
} finally {
holder.kill();
fs.rmSync(base, { recursive: true, force: true });
}
}, 30000);
});
+8 -6
View File
@@ -207,12 +207,14 @@ describe('two-class referenced-paths (ENG-OV7)', () => {
});
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);
// Guard against the extraction regex silently rotting: the review skill's
// checklist refs are KNOWN to exist. Since #2518 they anchor at the
// installed gstack root (class 2: gstack/review/checklist.md), not the
// alias-relative form — if the scanner stops seeing them, the class-2
// assertion is vacuous.
const refs = collectRefs();
expect(refs.length).toBeGreaterThan(0);
expect(refs.some((r) => r.skillName === 'gstack' && r.rel === 'review/checklist.md')).toBe(true);
});
test('KNOWN_BROKEN_CLASS2 entries are still actually broken (ratchet)', () => {
+26 -6
View File
@@ -6,7 +6,8 @@
* 1. Set up a fake $HOME with a Claude Code project + a Codex session +
* ~/.gstack/ artifacts (eureka, learning, ceo-plan, design-doc, retro,
* builder-profile)
* 2. Run gstack-memory-ingest --probe verify counts match disk
* 2. Run gstack-memory-ingest --probe verify stage counts match disk
* (post-attribution headline + unattributed skip line, #2394)
* 3. Run gstack-memory-ingest --bulk verify state file gets written +
* session_id dedup works on re-run (idempotency)
* 4. Run gstack-gbrain-sync --dry-run verify all 3 stages preview
@@ -98,7 +99,7 @@ function runBun(script: string, args: string[], env: Record<string, string>): {
// ── E2E pipeline ───────────────────────────────────────────────────────────
describe("V1 memory ingest pipeline E2E", () => {
it("--probe finds all 9 fixture files across all source types", () => {
it("--probe accounts for all 9 fixture files: 7 attributable + 2 unattributed transcripts skipped (#2394)", () => {
const home = makeFixtureHome();
const { gstackHome, counts } = setupFixture(home);
const env = { HOME: home, GSTACK_HOME: gstackHome, GSTACK_MEMORY_INGEST_NO_WRITE: "1" };
@@ -106,11 +107,15 @@ describe("V1 memory ingest pipeline E2E", () => {
const r = runBun(INGEST, ["--probe"], env);
expect(r.exitCode).toBe(0);
const totalExpected = Object.values(counts).reduce((s, n) => s + n, 0);
expect(r.stdout).toContain(`Total files in window: ${totalExpected}`);
// #2394: probe counts what --bulk would ingest. The fixture transcripts
// carry no resolvable git remote, so the shared attribution gate skips
// both; the gstack artifacts are store-local and always attributable.
const transcripts = counts.transcript;
const attributable = Object.values(counts).reduce((s, n) => s + n, 0) - transcripts;
expect(r.stdout).toContain(`Total files in window: ${attributable}`);
expect(r.stdout).toContain(`Skipped (unattributed): ${transcripts}`);
// Spot-check that each type appears with the right count
expect(r.stdout).toMatch(/transcript\s+2/);
// Spot-check that each artifact type appears with the right count
expect(r.stdout).toMatch(/eureka\s+1/);
expect(r.stdout).toMatch(/learning\s+1/);
expect(r.stdout).toMatch(/ceo-plan\s+1/);
@@ -118,6 +123,21 @@ describe("V1 memory ingest pipeline E2E", () => {
rmSync(home, { recursive: true, force: true });
});
it("--probe --include-unattributed counts all 9 fixture files, transcripts included", () => {
const home = makeFixtureHome();
const { gstackHome, counts } = setupFixture(home);
const env = { HOME: home, GSTACK_HOME: gstackHome, GSTACK_MEMORY_INGEST_NO_WRITE: "1" };
const r = runBun(INGEST, ["--probe", "--include-unattributed"], env);
expect(r.exitCode).toBe(0);
const totalExpected = Object.values(counts).reduce((s, n) => s + n, 0);
expect(r.stdout).toContain(`Total files in window: ${totalExpected}`);
expect(r.stdout).toMatch(/transcript\s+2/);
rmSync(home, { recursive: true, force: true });
});
it("--incremental writes a state file with schema_version: 1 + last_writer", () => {
const home = makeFixtureHome();
const { gstackHome } = setupFixture(home);
+236
View File
@@ -225,6 +225,8 @@ describe('timeline-stop-hook (#2553, F5 fail-open)', () => {
});
describe('timeline-stop-hook wiring', () => {
const SETTINGS_HOOK = path.join(ROOT, 'bin', 'gstack-settings-hook');
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');
@@ -235,6 +237,240 @@ describe('timeline-stop-hook wiring', () => {
expect(teardown).toContain('remove-source --source gstack-timeline-stop');
});
test('setup surfaces a settings-hook refusal instead of swallowing it', () => {
// The hardened settings-hook refuses to rewrite a corrupt settings.json
// (exit 1). Both setup call sites (ALREADY_INSTALLED plan-tune re-point,
// timeline ensure-event) must stay non-fatal but PRINT the failure.
const setup = fs.readFileSync(path.join(ROOT, 'setup'), 'utf-8');
const warnings = setup.match(/settings hook update failed/g) || [];
expect(warnings.length).toBeGreaterThanOrEqual(2);
// The old swallow patterns are gone (the --no-team remove-source teardown
// legitimately keeps its 2>/dev/null; only the ensure-event registration
// must surface stderr).
expect(setup).not.toContain('_install_plan_tune_hooks >/dev/null 2>&1 || true');
expect(setup).not.toMatch(/ensure-event[\s\S]{0,220}--source gstack-timeline-stop[\s\S]{0,40}2>\/dev\/null/);
});
test('setup routes the Stop hook through ensure-event, not presence-only dedup', () => {
const setup = fs.readFileSync(path.join(ROOT, 'setup'), 'utf-8');
// ensure-event registers when missing AND re-points a stale path in place.
expect(setup).toMatch(/ensure-event[\s\S]{0,220}--source gstack-timeline-stop/);
// The old guard skipped registration whenever the source tag was merely
// PRESENT, so a stale absolute path (deleted dev worktree) was never
// re-pointed on a setup re-run.
expect(setup).not.toMatch(/list-sources 2>\/dev\/null \| grep -q "gstack-timeline-stop"/);
});
test('hook path resolution is canonical-only: global install or skip, never the worktree', () => {
// Drive setup's _hook_command_path directly: canonical install present →
// that path (survives deleting the worktree setup ran from). Absent →
// non-zero and NO output — registration is skipped with a log line; the
// running tree's path is NEVER baked into settings.json (the SOURCE
// fallback was the phantom-hooks defect and is deliberately gone).
const setup = fs.readFileSync(path.join(ROOT, 'setup'), 'utf-8');
const fn = setup.match(/_hook_command_path\(\) \{[\s\S]*?\n\}/);
expect(fn).not.toBeNull();
const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-hookpath-'));
try {
const canonicalRoot = path.join(fakeHome, '.claude', 'skills', 'gstack');
const globalHook = path.join(canonicalRoot, 'hosts', 'claude', 'hooks', 'timeline-stop-hook');
fs.mkdirSync(path.dirname(globalHook), { recursive: true });
fs.writeFileSync(globalHook, '#!/bin/sh\nexit 0\n', { mode: 0o755 });
const env = {
...process.env,
HOME: fakeHome,
SOURCE_GSTACK_DIR: '/some/dev/worktree',
CANONICAL_GSTACK_ROOT: canonicalRoot,
};
const withGlobal = spawnSync(
'bash',
['-c', `${fn![0]}\n_hook_command_path hosts/claude/hooks/timeline-stop-hook`],
{ env, encoding: 'utf-8', timeout: 10_000 },
);
expect(withGlobal.status).toBe(0);
expect(withGlobal.stdout.trim()).toBe(globalHook);
// No canonical install → the resolver FAILS (caller logs a visible
// skip); it never falls back to the setup-time tree.
fs.rmSync(globalHook);
const withoutGlobal = spawnSync(
'bash',
['-c', `${fn![0]}\n_hook_command_path hosts/claude/hooks/timeline-stop-hook`],
{ env, encoding: 'utf-8', timeout: 10_000 },
);
expect(withoutGlobal.status).not.toBe(0);
expect(withoutGlobal.stdout.trim()).toBe('');
} finally {
fs.rmSync(fakeHome, { recursive: true, force: true });
}
});
test('ensure-event re-points a stale absolute path and leaves exactly one registration', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-ensure-'));
try {
const settingsFile = path.join(dir, 'settings.json');
fs.writeFileSync(settingsFile, JSON.stringify({
hooks: {
Stop: [{
_gstack_source: 'gstack-timeline-stop',
hooks: [{ type: 'command', command: '/deleted/worktree/hosts/claude/hooks/timeline-stop-hook', timeout: 5 }],
}],
},
}, null, 2) + '\n');
const r = spawnSync('bash', [
SETTINGS_HOOK, 'ensure-event',
'--event', 'Stop',
'--command', HOOK,
'--source', 'gstack-timeline-stop',
'--timeout', '5',
], { env: { ...process.env, GSTACK_SETTINGS_FILE: settingsFile }, encoding: 'utf-8', timeout: 15_000 });
expect(r.status).toBe(0);
expect(r.stdout).toContain('re-pointed');
const s = JSON.parse(fs.readFileSync(settingsFile, 'utf-8'));
expect(s.hooks.Stop).toHaveLength(1); // replaced in place — never two
expect(s.hooks.Stop[0].hooks[0].command).toBe(HOOK);
expect(s.hooks.Stop[0]._gstack_source).toBe('gstack-timeline-stop');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('ensure-event is a true no-op when the registration already matches (no write, no backup churn)', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-ensure-noop-'));
try {
const settingsFile = path.join(dir, 'settings.json');
const args = [
SETTINGS_HOOK, 'ensure-event',
'--event', 'Stop',
'--command', HOOK,
'--source', 'gstack-timeline-stop',
'--timeout', '5',
];
const env = { ...process.env, GSTACK_SETTINGS_FILE: settingsFile };
const first = spawnSync('bash', args, { env, encoding: 'utf-8', timeout: 15_000 });
expect(first.status).toBe(0);
const bytesAfterFirst = fs.readFileSync(settingsFile, 'utf-8');
const second = spawnSync('bash', args, { env, encoding: 'utf-8', timeout: 15_000 });
expect(second.status).toBe(0);
expect(second.stdout).toContain('unchanged');
expect(fs.readFileSync(settingsFile, 'utf-8')).toBe(bytesAfterFirst);
// Re-running ./setup must not accumulate settings.json.bak.<ts> files.
const baks = fs.readdirSync(dir).filter((f) => f.includes('.bak'));
expect(baks).toEqual([]);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('corrupt settings.json: ensure-event refuses (exit 3) and never rewrites the file', () => {
// The old catch{} folded an unparseable EXISTING settings.json into {}
// and the atomic write replaced the user's permissions/env/other hooks
// with just ours. Now: loud stderr error, fail-closed exit 3 (the
// settings-hook parse-refusal code), file byte-identical.
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-ensure-corrupt-'));
try {
const settingsFile = path.join(dir, 'settings.json');
const corrupt = '{ "permissions": { "allow": ["Bash(npm:*)"] }, INVALID';
fs.writeFileSync(settingsFile, corrupt);
const r = spawnSync('bash', [
SETTINGS_HOOK, 'ensure-event',
'--event', 'Stop',
'--command', HOOK,
'--source', 'gstack-timeline-stop',
'--timeout', '5',
], { env: { ...process.env, GSTACK_SETTINGS_FILE: settingsFile }, encoding: 'utf-8', timeout: 15_000 });
expect(r.status).toBe(3);
expect(r.stderr).toContain('not valid JSON');
// Never rewritten — the corrupt bytes (and whatever the user can still
// salvage from them) survive verbatim.
expect(fs.readFileSync(settingsFile, 'utf-8')).toBe(corrupt);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('a matcher change updates the tagged entry in place — still exactly one registration', () => {
// Identity key is (event, source): an existing gstack entry with a STALE
// matcher must be updated, never joined by a second entry (the old key
// included the matcher, so any future matcher change would duplicate).
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-ensure-matcher-'));
try {
const settingsFile = path.join(dir, 'settings.json');
fs.writeFileSync(settingsFile, JSON.stringify({
hooks: {
PreToolUse: [{
_gstack_source: 'gstack-plan-tune',
matcher: 'OldMatcher',
hooks: [{ type: 'command', command: '/old/path/hook', timeout: 5 }],
}],
},
}, null, 2) + '\n');
const r = spawnSync('bash', [
SETTINGS_HOOK, 'ensure-event',
'--event', 'PreToolUse',
'--command', '/new/path/hook',
'--source', 'gstack-plan-tune',
'--matcher', 'NewMatcher',
'--timeout', '5',
], { env: { ...process.env, GSTACK_SETTINGS_FILE: settingsFile }, encoding: 'utf-8', timeout: 15_000 });
expect(r.status).toBe(0);
const s = JSON.parse(fs.readFileSync(settingsFile, 'utf-8'));
expect(s.hooks.PreToolUse).toHaveLength(1); // updated in place — never two
expect(s.hooks.PreToolUse[0].matcher).toBe('NewMatcher');
expect(s.hooks.PreToolUse[0].hooks[0].command).toBe('/new/path/hook');
expect(s.hooks.PreToolUse[0]._gstack_source).toBe('gstack-plan-tune');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('a failed update leaves exactly one registration — never zero, never two', () => {
// Root can write through 0o555 directories, so the failure injection
// (read-only dir) does not bind there; the invariant is still covered by
// the atomic tmp+rename pinned in the re-point test above.
if (typeof process.getuid === 'function' && process.getuid() === 0) return;
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-ensure-fail-'));
try {
const settingsFile = path.join(dir, 'settings.json');
fs.writeFileSync(settingsFile, JSON.stringify({
hooks: {
Stop: [{
_gstack_source: 'gstack-timeline-stop',
hooks: [{ type: 'command', command: '/stale/path/timeline-stop-hook', timeout: 5 }],
}],
},
}, null, 2) + '\n');
fs.chmodSync(dir, 0o555); // every write path (backup, tmp, rename) fails
const r = spawnSync('bash', [
SETTINGS_HOOK, 'ensure-event',
'--event', 'Stop',
'--command', HOOK,
'--source', 'gstack-timeline-stop',
'--timeout', '5',
], { env: { ...process.env, GSTACK_SETTINGS_FILE: settingsFile }, encoding: 'utf-8', timeout: 15_000 });
fs.chmodSync(dir, 0o755);
expect(r.status).not.toBe(0); // the failure is loud, not swallowed
const s = JSON.parse(fs.readFileSync(settingsFile, 'utf-8'));
expect(s.hooks.Stop).toHaveLength(1); // old registration intact
expect(s.hooks.Stop[0].hooks[0].command).toBe('/stale/path/timeline-stop-hook');
} finally {
try { fs.chmodSync(dir, 0o755); } catch {}
fs.rmSync(dir, { recursive: true, force: true });
}
});
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');
+53
View File
@@ -0,0 +1,53 @@
/**
* Static pins for the v1.68 wave's prose-tier behaviors the coverage audit
* flagged these as the only surfaces a future template edit could silently
* revert without failing anything.
*/
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 read = (p: string) => fs.readFileSync(path.join(ROOT, p), 'utf-8');
describe('gstack-upgrade template: ff-only precedes the gated reset (#2517)', () => {
const tmpl = read('gstack-upgrade/SKILL.md.tmpl');
test('git pull --ff-only runs before any reset --hard', () => {
const ff = tmpl.indexOf('git pull --ff-only --autostash');
const reset = tmpl.indexOf('git reset --hard origin/main');
expect(ff).toBeGreaterThan(-1);
expect(reset).toBeGreaterThan(-1);
expect(ff).toBeLessThan(reset);
});
test('the ff path carries the FF_OK success gate that skips the fallback', () => {
expect(tmpl).toContain('FF_OK');
expect(tmpl.indexOf('FF_OK')).toBeLessThan(tmpl.indexOf('git reset --hard origin/main'));
});
test('the destructive fallback is gated on unpushed commits, not just a clean tree', () => {
// A clean tree with unpushed local commits is NOT safe for reset --hard.
expect(tmpl).toContain('git rev-list origin/main..HEAD');
expect(tmpl.indexOf('git rev-list origin/main..HEAD')).toBeLessThan(
tmpl.indexOf('git reset --hard origin/main'),
);
});
});
describe('untrusted-content warning injection points (#2441)', () => {
test('scrape and skillify templates carry the shared token', () => {
// The wording lives in ONE exported const (resolvers/browse.ts); these
// pins keep the injection POINTS from silently disappearing.
expect(read('scrape/SKILL.md.tmpl')).toContain('{{UNTRUSTED_CONTENT_WARNING}}');
expect(read('skillify/SKILL.md.tmpl')).toContain('{{UNTRUSTED_CONTENT_WARNING}}');
});
});
describe('brain-uninstall removes the spool queue', () => {
test('uninstall cleans .brain-queue.d alongside the legacy queue file', () => {
const src = read('bin/gstack-brain-uninstall');
expect(src).toContain('.brain-queue.d');
expect(src).toContain('.brain-queue.jsonl');
});
});