mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-09 22:48:57 +02:00
fix: read project-scoped MCP registrations in gbrain detection (#2499)
Claude Code registers MCP servers at two scopes in ~/.claude.json: user scope (.mcpServers) and project scope (.projects["/abs/path"].mcpServers — what `claude mcp add` WITHOUT --scope user writes). Every gbrain detection site read only user scope, so a correctly configured project-scoped brain was invisible: brain-aware blocks suppressed, remote-mode artifacts sync never recognised, and detectEndpointHash fell through to the 'local' literal — two different project-scoped brains hashed identically, so switching between them never invalidated the cache, the exact scenario the function's docstring says it exists to catch. Nothing errored; the features just quietly were not there. Two sites fixed: - scripts/resolvers/preamble/generate-brain-sync-block.ts: the shared detection block (rendered into every tier-2+ SKILL.md) now resolves the gbrain entry ONCE into _GBRAIN_MCP_ENTRY — user scope first, then the nearest-ancestor project entry for $PWD that actually carries a gbrain server (longest matching key with a path-boundary check: /a/repo never matches /a/repo2; a nested project WITHOUT gbrain doesn't shadow its parent's registration). _GBRAIN_MCP_TYPE and _GBRAIN_HOST extract from the resolved entry, so claude.json is parsed once per skill start. All SKILL.md files regenerated in this commit; the ship golden fixtures and three carve-guard skeleton caps (plan-eng-review, plan-devex-review, office-hours; ~1.5KB rendered growth per skill) are refreshed with measured values. - bin/gstack-brain-cache detectEndpointHash: same resolution order in TS (user scope, else nearest-ancestor project entry by cwd, both path separators for Windows keys). Tests: rendered-output tests in test/gen-skill-docs.test.ts pin the regenerated block (static markers + a FUNCTIONAL run of the exact rendered lines against a fixture ~/.claude.json with only a project-scoped registration, plus an outside-cwd discriminator); detectEndpointHash unit tests in test/brain-cache-roundtrip.test.ts cover project-scope resolve, path-boundary, nearest-ancestor distinct hashes, and user-scope precedence. Root-cause analysis by @samporter-31 in #2499. Fixes #2499 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
5854d122d3
commit
acc354fcfa
@@ -131,6 +131,59 @@ describe('brain-cache endpoint detection', () => {
|
||||
expect(typeof hash).toBe('string');
|
||||
expect(hash.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// #2499: project-scoped registrations (.projects["/path"].mcpServers.gbrain)
|
||||
// were never read — two different project-scoped brains both hashed to
|
||||
// 'local', so switching between them never invalidated the cache.
|
||||
test('detectEndpointHash resolves a project-scoped gbrain URL for a cwd inside the project (#2499)', async () => {
|
||||
const mod = await importCache();
|
||||
const cj = join(TMP_HOME, 'claude.json');
|
||||
writeFileSync(cj, JSON.stringify({
|
||||
projects: {
|
||||
'/w/repo': { mcpServers: { gbrain: { type: 'http', url: 'https://a.example/mcp' } } },
|
||||
},
|
||||
}));
|
||||
const inside = mod.detectEndpointHash(cj, '/w/repo/src/deep');
|
||||
expect(inside).not.toBe('local');
|
||||
expect(inside).toHaveLength(8);
|
||||
// Path-boundary check: /w/repo2 is NOT inside /w/repo.
|
||||
expect(mod.detectEndpointHash(cj, '/w/repo2')).toBe('local');
|
||||
});
|
||||
|
||||
test('detectEndpointHash prefers the nearest-ancestor project entry (#2499)', async () => {
|
||||
const mod = await importCache();
|
||||
const cj = join(TMP_HOME, 'claude.json');
|
||||
writeFileSync(cj, JSON.stringify({
|
||||
projects: {
|
||||
'/w/repo': { mcpServers: { gbrain: { url: 'https://outer.example/mcp' } } },
|
||||
'/w/repo/nested': { mcpServers: { gbrain: { url: 'https://inner.example/mcp' } } },
|
||||
},
|
||||
}));
|
||||
const inner = mod.detectEndpointHash(cj, '/w/repo/nested/sub');
|
||||
const outer = mod.detectEndpointHash(cj, '/w/repo/other');
|
||||
expect(inner).not.toBe(outer); // two brains → two hashes (the docstring scenario)
|
||||
expect(inner).not.toBe('local');
|
||||
expect(outer).not.toBe('local');
|
||||
});
|
||||
|
||||
test('detectEndpointHash still prefers user scope over project scope (#2499)', async () => {
|
||||
const mod = await importCache();
|
||||
const cj = join(TMP_HOME, 'claude.json');
|
||||
writeFileSync(cj, JSON.stringify({
|
||||
mcpServers: { gbrain: { url: 'https://user.example/mcp' } },
|
||||
projects: {
|
||||
'/w/repo': { mcpServers: { gbrain: { url: 'https://proj.example/mcp' } } },
|
||||
},
|
||||
}));
|
||||
const userScoped = mod.detectEndpointHash(cj, '/w/repo');
|
||||
// Same file minus the user-scope entry → different hash proves user scope won.
|
||||
writeFileSync(cj, JSON.stringify({
|
||||
projects: {
|
||||
'/w/repo': { mcpServers: { gbrain: { url: 'https://proj.example/mcp' } } },
|
||||
},
|
||||
}));
|
||||
expect(mod.detectEndpointHash(cj, '/w/repo')).not.toBe(userScoped);
|
||||
});
|
||||
});
|
||||
|
||||
describe('brain-cache schema mismatch behavior', () => {
|
||||
|
||||
+6
-3
@@ -508,10 +508,13 @@ _BRAIN_SYNC_MODE=$("$_BRAIN_CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || e
|
||||
# Detect remote-MCP mode (Path 4 of /setup-gbrain). Local artifacts sync is
|
||||
# a no-op in remote mode; the brain server pulls from GitHub/GitLab on its
|
||||
# own cadence. Read claude.json directly to keep this preamble fast (no
|
||||
# subprocess to claude CLI on every skill start).
|
||||
# subprocess to claude CLI on every skill start). Both registration scopes
|
||||
# are read (#2499): user scope, then the nearest-ancestor project scope.
|
||||
_GBRAIN_MCP_MODE="none"
|
||||
_GBRAIN_MCP_ENTRY=""
|
||||
if command -v jq >/dev/null 2>&1 && [ -f "$HOME/.claude.json" ]; then
|
||||
_GBRAIN_MCP_TYPE=$(jq -r '.mcpServers.gbrain.type // .mcpServers.gbrain.transport // empty' "$HOME/.claude.json" 2>/dev/null)
|
||||
_GBRAIN_MCP_ENTRY=$(jq -c --arg cwd "$PWD" '.mcpServers.gbrain // ((.projects // {}) | to_entries | map(select((.key as $k | $cwd == $k or ($cwd | startswith($k + "/"))) and ((try .value.mcpServers.gbrain catch null) != null))) | sort_by(.key | length) | last | .value.mcpServers.gbrain) // empty' "$HOME/.claude.json" 2>/dev/null)
|
||||
_GBRAIN_MCP_TYPE=$(printf '%s' "$_GBRAIN_MCP_ENTRY" | jq -r '.type // .transport // empty' 2>/dev/null)
|
||||
case "$_GBRAIN_MCP_TYPE" in
|
||||
url|http|sse) _GBRAIN_MCP_MODE="remote-http" ;;
|
||||
stdio) _GBRAIN_MCP_MODE="local-stdio" ;;
|
||||
@@ -546,7 +549,7 @@ fi
|
||||
if [ "$_GBRAIN_MCP_MODE" = "remote-http" ]; then
|
||||
# Remote-MCP mode: local artifacts sync is a no-op (brain admin's server
|
||||
# pulls from GitHub/GitLab). Show the user this is by design, not broken.
|
||||
_GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|' | head -1 | tr -cd 'A-Za-z0-9._-')
|
||||
_GBRAIN_HOST=$(printf '%s' "${_GBRAIN_MCP_ENTRY:-}" | jq -r '.url // empty' 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|' | head -1 | tr -cd 'A-Za-z0-9._-')
|
||||
echo "ARTIFACTS_SYNC: remote-mode (managed by brain server ${_GBRAIN_HOST:-remote})"
|
||||
elif [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then
|
||||
_BRAIN_QUEUE_DEPTH=0
|
||||
|
||||
+6
-3
@@ -494,10 +494,13 @@ _BRAIN_SYNC_MODE=$("$_BRAIN_CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || e
|
||||
# Detect remote-MCP mode (Path 4 of /setup-gbrain). Local artifacts sync is
|
||||
# a no-op in remote mode; the brain server pulls from GitHub/GitLab on its
|
||||
# own cadence. Read claude.json directly to keep this preamble fast (no
|
||||
# subprocess to claude CLI on every skill start).
|
||||
# subprocess to claude CLI on every skill start). Both registration scopes
|
||||
# are read (#2499): user scope, then the nearest-ancestor project scope.
|
||||
_GBRAIN_MCP_MODE="none"
|
||||
_GBRAIN_MCP_ENTRY=""
|
||||
if command -v jq >/dev/null 2>&1 && [ -f "$HOME/.claude.json" ]; then
|
||||
_GBRAIN_MCP_TYPE=$(jq -r '.mcpServers.gbrain.type // .mcpServers.gbrain.transport // empty' "$HOME/.claude.json" 2>/dev/null)
|
||||
_GBRAIN_MCP_ENTRY=$(jq -c --arg cwd "$PWD" '.mcpServers.gbrain // ((.projects // {}) | to_entries | map(select((.key as $k | $cwd == $k or ($cwd | startswith($k + "/"))) and ((try .value.mcpServers.gbrain catch null) != null))) | sort_by(.key | length) | last | .value.mcpServers.gbrain) // empty' "$HOME/.claude.json" 2>/dev/null)
|
||||
_GBRAIN_MCP_TYPE=$(printf '%s' "$_GBRAIN_MCP_ENTRY" | jq -r '.type // .transport // empty' 2>/dev/null)
|
||||
case "$_GBRAIN_MCP_TYPE" in
|
||||
url|http|sse) _GBRAIN_MCP_MODE="remote-http" ;;
|
||||
stdio) _GBRAIN_MCP_MODE="local-stdio" ;;
|
||||
@@ -532,7 +535,7 @@ fi
|
||||
if [ "$_GBRAIN_MCP_MODE" = "remote-http" ]; then
|
||||
# Remote-MCP mode: local artifacts sync is a no-op (brain admin's server
|
||||
# pulls from GitHub/GitLab). Show the user this is by design, not broken.
|
||||
_GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|' | head -1 | tr -cd 'A-Za-z0-9._-')
|
||||
_GBRAIN_HOST=$(printf '%s' "${_GBRAIN_MCP_ENTRY:-}" | jq -r '.url // empty' 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|' | head -1 | tr -cd 'A-Za-z0-9._-')
|
||||
echo "ARTIFACTS_SYNC: remote-mode (managed by brain server ${_GBRAIN_HOST:-remote})"
|
||||
elif [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then
|
||||
_BRAIN_QUEUE_DEPTH=0
|
||||
|
||||
+6
-3
@@ -496,10 +496,13 @@ _BRAIN_SYNC_MODE=$("$_BRAIN_CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || e
|
||||
# Detect remote-MCP mode (Path 4 of /setup-gbrain). Local artifacts sync is
|
||||
# a no-op in remote mode; the brain server pulls from GitHub/GitLab on its
|
||||
# own cadence. Read claude.json directly to keep this preamble fast (no
|
||||
# subprocess to claude CLI on every skill start).
|
||||
# subprocess to claude CLI on every skill start). Both registration scopes
|
||||
# are read (#2499): user scope, then the nearest-ancestor project scope.
|
||||
_GBRAIN_MCP_MODE="none"
|
||||
_GBRAIN_MCP_ENTRY=""
|
||||
if command -v jq >/dev/null 2>&1 && [ -f "$HOME/.claude.json" ]; then
|
||||
_GBRAIN_MCP_TYPE=$(jq -r '.mcpServers.gbrain.type // .mcpServers.gbrain.transport // empty' "$HOME/.claude.json" 2>/dev/null)
|
||||
_GBRAIN_MCP_ENTRY=$(jq -c --arg cwd "$PWD" '.mcpServers.gbrain // ((.projects // {}) | to_entries | map(select((.key as $k | $cwd == $k or ($cwd | startswith($k + "/"))) and ((try .value.mcpServers.gbrain catch null) != null))) | sort_by(.key | length) | last | .value.mcpServers.gbrain) // empty' "$HOME/.claude.json" 2>/dev/null)
|
||||
_GBRAIN_MCP_TYPE=$(printf '%s' "$_GBRAIN_MCP_ENTRY" | jq -r '.type // .transport // empty' 2>/dev/null)
|
||||
case "$_GBRAIN_MCP_TYPE" in
|
||||
url|http|sse) _GBRAIN_MCP_MODE="remote-http" ;;
|
||||
stdio) _GBRAIN_MCP_MODE="local-stdio" ;;
|
||||
@@ -534,7 +537,7 @@ fi
|
||||
if [ "$_GBRAIN_MCP_MODE" = "remote-http" ]; then
|
||||
# Remote-MCP mode: local artifacts sync is a no-op (brain admin's server
|
||||
# pulls from GitHub/GitLab). Show the user this is by design, not broken.
|
||||
_GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|' | head -1 | tr -cd 'A-Za-z0-9._-')
|
||||
_GBRAIN_HOST=$(printf '%s' "${_GBRAIN_MCP_ENTRY:-}" | jq -r '.url // empty' 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|' | head -1 | tr -cd 'A-Za-z0-9._-')
|
||||
echo "ARTIFACTS_SYNC: remote-mode (managed by brain server ${_GBRAIN_HOST:-remote})"
|
||||
elif [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then
|
||||
_BRAIN_QUEUE_DEPTH=0
|
||||
|
||||
@@ -3604,3 +3604,83 @@ describe('PREAMBLE resolution requires declared preamble-tier', () => {
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #2499: gbrain MCP detection must read BOTH ~/.claude.json scopes.
|
||||
// Claude Code registers MCP servers at user scope (.mcpServers) and project
|
||||
// scope (.projects["/abs/path"].mcpServers — what `claude mcp add` without
|
||||
// --scope user writes). The rendered brain-sync block previously read only
|
||||
// user scope, so a correctly configured project-scoped brain was invisible.
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('brain-sync block reads project-scoped MCP registrations (#2499)', () => {
|
||||
const rendered = fs.readFileSync(path.join(ROOT, 'SKILL.md'), 'utf-8');
|
||||
|
||||
test('rendered _GBRAIN_MCP_ENTRY jq resolves project scope with nearest-ancestor cwd match', () => {
|
||||
const line = rendered.split('\n').find((l) => l.includes('_GBRAIN_MCP_ENTRY=$('));
|
||||
expect(line).toBeDefined();
|
||||
// Project-scope read present, driven by $PWD.
|
||||
expect(line!).toContain('--arg cwd "$PWD"');
|
||||
expect(line!).toContain('.projects');
|
||||
// User scope still resolved first.
|
||||
expect(line!).toContain('.mcpServers.gbrain');
|
||||
// The old user-scope-only filter is gone from the rendered output.
|
||||
expect(rendered).not.toContain('.mcpServers.gbrain.type // .mcpServers.gbrain.transport');
|
||||
expect(rendered).not.toContain(".mcpServers.gbrain.url // empty");
|
||||
});
|
||||
|
||||
test('rendered _GBRAIN_MCP_TYPE and _GBRAIN_HOST extract from the resolved entry', () => {
|
||||
const typeLine = rendered.split('\n').find((l) => l.includes('_GBRAIN_MCP_TYPE=$('));
|
||||
const hostLine = rendered.split('\n').find((l) => l.includes('_GBRAIN_HOST=$('));
|
||||
expect(typeLine).toBeDefined();
|
||||
expect(hostLine).toBeDefined();
|
||||
expect(typeLine!).toContain('_GBRAIN_MCP_ENTRY');
|
||||
expect(hostLine!).toContain('_GBRAIN_MCP_ENTRY');
|
||||
});
|
||||
|
||||
test('rendered jq lines FUNCTION: project-scoped registration resolves for a cwd inside the project', () => {
|
||||
// Execute the exact rendered bytes, not a re-derivation: extract the
|
||||
// _GBRAIN_MCP_ENTRY + _GBRAIN_MCP_TYPE lines from the generated SKILL.md
|
||||
// and run them in bash against a fixture ~/.claude.json that carries ONLY
|
||||
// a project-scoped gbrain registration.
|
||||
const lines = rendered.split('\n');
|
||||
const entryLine = lines.find((l) => l.includes('_GBRAIN_MCP_ENTRY=$('));
|
||||
const typeLine = lines.find((l) => l.includes('_GBRAIN_MCP_TYPE=$('));
|
||||
expect(entryLine).toBeDefined();
|
||||
expect(typeLine).toBeDefined();
|
||||
|
||||
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-2499-home-'));
|
||||
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-2499-proj-'));
|
||||
const nestedCwd = path.join(projectDir, 'src', 'deep');
|
||||
fs.mkdirSync(nestedCwd, { recursive: true });
|
||||
try {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpHome, '.claude.json'),
|
||||
JSON.stringify({
|
||||
projects: {
|
||||
[projectDir]: {
|
||||
mcpServers: { gbrain: { type: 'http', url: 'https://brain.example.com/mcp' } },
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
const script = `cd "$1" || exit 1\n${entryLine!.trim()}\n${typeLine!.trim()}\necho "RESOLVED:$_GBRAIN_MCP_TYPE"`;
|
||||
const r = spawnSync('bash', ['-c', script, 'bash', nestedCwd], {
|
||||
encoding: 'utf-8',
|
||||
env: { ...process.env, HOME: tmpHome },
|
||||
timeout: 10_000,
|
||||
});
|
||||
expect(r.stdout).toContain('RESOLVED:http');
|
||||
|
||||
// Discriminator: a cwd OUTSIDE the project must NOT resolve it.
|
||||
const outside = spawnSync('bash', ['-c', script, 'bash', os.tmpdir()], {
|
||||
encoding: 'utf-8',
|
||||
env: { ...process.env, HOME: tmpHome },
|
||||
timeout: 10_000,
|
||||
});
|
||||
expect(outside.stdout).toContain('RESOLVED:\n');
|
||||
} finally {
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
fs.rmSync(projectDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -181,7 +181,9 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
|
||||
// v1.65 merge: provisional larger-of-both-waves budget; re-measured below.
|
||||
// Fork port wave 2 (#703): the repo-doc-preference block in the design
|
||||
// check grew every plan-review skeleton ~0.7KB. Measured values noted.
|
||||
maxSkeletonBytes: 70_000, // measured 68,780
|
||||
// #2499 project-scope MCP jq in the brain-sync block grew every tier-2+
|
||||
// skeleton ~1.5KB (entry resolution emitted once per SKILL.md).
|
||||
maxSkeletonBytes: 70_500, // measured 70,318
|
||||
minUnionBytes: 70_000,
|
||||
mustContain: ['Architecture', 'Code Quality', 'Test', 'Performance'],
|
||||
// Cross-cutting preamble growth (v1.57.2.0 AUQ-failure prose fallback + the
|
||||
@@ -236,7 +238,9 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
|
||||
// v1.2.0 activation lift: first-run-guidance section in the shared preamble.
|
||||
// Fork port wave 2 (#703): the repo-doc-preference block in the design
|
||||
// check grew every plan-review skeleton ~0.7KB. Measured values noted.
|
||||
maxSkeletonBytes: 82_000, // measured 80,493
|
||||
// #2499 project-scope MCP jq in the brain-sync block grew every tier-2+
|
||||
// skeleton ~1.5KB (entry resolution emitted once per SKILL.md).
|
||||
maxSkeletonBytes: 82_500, // measured 82,031
|
||||
minUnionBytes: 70_000,
|
||||
mustContain: ['developer experience', 'Getting Started'],
|
||||
// Default-on Codex outside-voice (codexPreflight block + CODEX_MODE branch
|
||||
@@ -264,7 +268,9 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
|
||||
// (judgment must be visible before the workflow directs the user to a
|
||||
// vendor site), plus the #703 dual-write + repo-doc-preference block and
|
||||
// the #538 opt-out + D1 evidence directive — ratio 1.104 measured.
|
||||
maxSkeletonBytes: 101_000,
|
||||
// #2499 project-scope MCP jq in the brain-sync block grew every tier-2+
|
||||
// skeleton ~1.5KB (entry resolution emitted once per SKILL.md).
|
||||
maxSkeletonBytes: 101_500, // measured 101,314
|
||||
minUnionBytes: 70_000,
|
||||
mustContain: ['design doc', 'problem statement'],
|
||||
maxSizeRatio: 1.12,
|
||||
|
||||
Reference in New Issue
Block a user