diff --git a/bin/gstack-config b/bin/gstack-config index 2a6e9ff68..afb8bc205 100755 --- a/bin/gstack-config +++ b/bin/gstack-config @@ -108,19 +108,141 @@ lookup_default() { cross_project_learnings) echo "" ;; # intentionally empty → unset triggers first-time prompt artifacts_sync_mode) echo "off" ;; artifacts_sync_mode_prompted) echo "false" ;; + # Brain-aware planning (v1.48 / T5+T10+T16). Defaults documented inline: + # brain_trust_policy@ — unset on fresh install; setup-gbrain + # writes 'personal' for local engines, + # asks the user for remote-ambiguous. + # salience_allowlist — empty falls through to + # SALIENCE_DEFAULT_ALLOWLIST (D9). + # user_slug_at_ — empty triggers resolve-user-slug + # fallback chain (D4 A3) on first call. + brain_trust_policy*) echo "unset" ;; + salience_allowlist) echo "" ;; + user_slug_at_*) echo "" ;; *) echo "" ;; esac } +# ────────────────────────────────────────────────────────────────────── +# Brain-integration helpers (T5+T10+T16) +# ────────────────────────────────────────────────────────────────────── + +# Compute sha8 of a string. Used for endpoint hashing. +sha8_of() { + printf '%s' "$1" | shasum -a 256 | cut -c1-8 +} + +# Detect the active brain endpoint hash. Reads ~/.claude.json for the gbrain +# MCP server URL. Falls back to the literal 'local' when no MCP is configured. +endpoint_hash() { + _claude_json="$HOME/.claude.json" + if [ -f "$_claude_json" ] && command -v jq >/dev/null 2>&1; then + _url=$(jq -r '.mcpServers.gbrain.url // .mcpServers.gbrain.transport.url // empty' "$_claude_json" 2>/dev/null) + if [ -n "$_url" ] && [ "$_url" != "null" ]; then + sha8_of "$_url" + return 0 + fi + fi + printf '%s' "local" +} + +# Detect endpoint hash collisions. When two distinct endpoints share the same +# sha8 prefix (rare but possible), escalate to sha16 by emitting the longer +# hash. Detection: scan config file for existing brain_trust_policy@ or +# user_slug_at_ keys; if any non-active hash equals the active sha8 but +# would differ at sha16, the active endpoint needs sha16. +endpoint_hash_with_collision_check() { + _active=$(endpoint_hash) + if [ "$_active" = "local" ]; then + printf '%s' "$_active" + return 0 + fi + # If a different endpoint (different URL) shares this sha8, escalate. + # We only catch this when the config has another endpoint recorded. + _matching=$(grep -E "^(brain_trust_policy|user_slug_at)@${_active}" "$CONFIG_FILE" 2>/dev/null | head -1 || true) + _claude_json="$HOME/.claude.json" + if [ -n "$_matching" ] && [ -f "$_claude_json" ] && command -v jq >/dev/null 2>&1; then + _url=$(jq -r '.mcpServers.gbrain.url // .mcpServers.gbrain.transport.url // empty' "$_claude_json" 2>/dev/null) + _sha16=$(printf '%s' "$_url" | shasum -a 256 | cut -c1-16) + # Look for any sha16-namespaced key that conflicts. If a stored sha16 exists + # and differs from current sha16, that's the collision evidence; emit sha16. + _stored16=$(grep -E "^(brain_trust_policy|user_slug_at)@${_sha16}" "$CONFIG_FILE" 2>/dev/null | head -1 || true) + if [ -n "$_stored16" ]; then + printf '%s' "$_sha16" + return 0 + fi + fi + printf '%s' "$_active" +} + +# Resolve the user-slug per D4 A3 chain: +# 1. mcp__gbrain__whoami.client_name (best effort via gbrain CLI shell-out) +# 2. $USER env +# 3. sha8($(git config user.email)) +# 4. anonymous- +# Persists result via gstack-config set user_slug_at_ on first call. +resolve_user_slug() { + _hash=$(endpoint_hash_with_collision_check) + _stored=$(grep -E "^user_slug_at_${_hash}:" "$CONFIG_FILE" 2>/dev/null | tail -1 | awk '{print $2}' | tr -d '[:space:]' || true) + if [ -n "$_stored" ]; then + printf '%s' "$_stored" + return 0 + fi + + _slug="" + + # Layer 1: gbrain whoami + if command -v gbrain >/dev/null 2>&1; then + _whoami=$(gbrain whoami --json 2>/dev/null || true) + if [ -n "$_whoami" ] && command -v jq >/dev/null 2>&1; then + _client_name=$(printf '%s' "$_whoami" | jq -r '.client_name // .token_name // empty' 2>/dev/null || true) + if [ -n "$_client_name" ] && [ "$_client_name" != "null" ]; then + _slug=$(printf '%s' "$_client_name" | tr '[:upper:] ' '[:lower:]-' | tr -dc '[:alnum:]-') + fi + fi + fi + + # Layer 2: $USER + if [ -z "$_slug" ] && [ -n "${USER:-}" ]; then + _slug=$(printf '%s' "$USER" | tr '[:upper:] ' '[:lower:]-' | tr -dc '[:alnum:]-') + fi + + # Layer 3: sha8 of git email + if [ -z "$_slug" ]; then + _email=$(git config user.email 2>/dev/null || true) + if [ -n "$_email" ]; then + _slug="email-$(sha8_of "$_email")" + fi + fi + + # Layer 4: anonymous- + if [ -z "$_slug" ]; then + _slug="anonymous-$(sha8_of "$(hostname 2>/dev/null || echo unknown)")" + fi + + # Persist via direct file write (avoid recursion into gstack-config set) + mkdir -p "$STATE_DIR" + if [ ! -f "$CONFIG_FILE" ]; then + printf '%s' "$CONFIG_HEADER" > "$CONFIG_FILE" + fi + if ! grep -qE "^user_slug_at_${_hash}:" "$CONFIG_FILE" 2>/dev/null; then + echo "user_slug_at_${_hash}: ${_slug}" >> "$CONFIG_FILE" + fi + + printf '%s' "$_slug" +} + case "${1:-}" in get) KEY="${2:?Usage: gstack-config get }" - # Validate key (alphanumeric + underscore only) - if ! printf '%s' "$KEY" | grep -qE '^[a-zA-Z0-9_]+$'; then - echo "Error: key must contain only alphanumeric characters and underscores" >&2 + # Validate key (alphanumeric + underscore + optional @ suffix for + # endpoint-namespaced keys introduced by the brain-aware planning layer) + if ! printf '%s' "$KEY" | grep -qE '^[a-zA-Z0-9_]+(@[a-f0-9]+)?$'; then + echo "Error: key must contain only alphanumeric characters, underscores, and an optional @ suffix" >&2 exit 1 fi - VALUE=$(grep -E "^${KEY}:" "$CONFIG_FILE" 2>/dev/null | tail -1 | awk '{print $2}' | tr -d '[:space:]' || true) + # Use literal match for keys containing @ (sha hashes), regex otherwise + VALUE=$(grep -F "${KEY}:" "$CONFIG_FILE" 2>/dev/null | grep -E "^${KEY%@*}(@[a-f0-9]+)?:" | grep -F "${KEY}:" | tail -1 | awk '{print $2}' | tr -d '[:space:]' || true) if [ -z "$VALUE" ]; then VALUE=$(lookup_default "$KEY") fi @@ -129,11 +251,17 @@ case "${1:-}" in set) KEY="${2:?Usage: gstack-config set }" VALUE="${3:?Usage: gstack-config set }" - # Validate key (alphanumeric + underscore only) - if ! printf '%s' "$KEY" | grep -qE '^[a-zA-Z0-9_]+$'; then - echo "Error: key must contain only alphanumeric characters and underscores" >&2 + # Validate key (alphanumeric + underscore + optional @ suffix) + if ! printf '%s' "$KEY" | grep -qE '^[a-zA-Z0-9_]+(@[a-f0-9]+)?$'; then + echo "Error: key must contain only alphanumeric characters, underscores, and an optional @ suffix" >&2 exit 1 fi + # Validate brain_trust_policy value domain (D4 / D11) + if printf '%s' "$KEY" | grep -qE '^brain_trust_policy(@|$)' && \ + [ "$VALUE" != "personal" ] && [ "$VALUE" != "shared" ] && [ "$VALUE" != "unset" ]; then + echo "Warning: brain_trust_policy '$VALUE' not recognized. Valid values: personal, shared, unset. Using unset." >&2 + VALUE="unset" + fi # V1: whitelist values for keys with closed value domains. Unknown values warn + default. if [ "$KEY" = "explain_level" ] && [ "$VALUE" != "default" ] && [ "$VALUE" != "terse" ]; then echo "Warning: explain_level '$VALUE' not recognized. Valid values: default, terse. Using default." >&2 @@ -192,8 +320,16 @@ case "${1:-}" in printf ' %-24s %s\n' "$KEY:" "$(lookup_default "$KEY")" done ;; + endpoint-hash) + # Brain integration helper (T10): print active brain endpoint sha8 + endpoint_hash_with_collision_check + ;; + resolve-user-slug) + # Brain integration helper (T16 / D4 A3): resolve + persist user-slug + resolve_user_slug + ;; *) - echo "Usage: gstack-config {get|set|list|defaults} [key] [value]" + echo "Usage: gstack-config {get|set|list|defaults|endpoint-hash|resolve-user-slug} [key] [value]" exit 1 ;; esac diff --git a/test/user-slug-fallback.test.ts b/test/user-slug-fallback.test.ts new file mode 100644 index 000000000..1d8c3f925 --- /dev/null +++ b/test/user-slug-fallback.test.ts @@ -0,0 +1,161 @@ +/** + * User-slug identity resolution chain (T16 / D4 A3). + * + * Verifies the gstack-config resolve-user-slug subcommand walks the + * documented fallback chain: + * 1. mcp__gbrain__whoami.client_name (skipped when gbrain not on PATH) + * 2. $USER env var + * 3. sha8($(git config user.email)) + * 4. anonymous- + * + * Result is persisted under user_slug_at_ for stability. + * Test isolation via GSTACK_HOME and HOME env overrides. + * + * Gate-tier, free, ~50ms. + */ + +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import { mkdtempSync, existsSync, readFileSync, writeFileSync, rmSync, mkdirSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { spawnSync } from 'child_process'; + +const REPO_ROOT = process.cwd(); +const CONFIG_BIN = join(REPO_ROOT, 'bin', 'gstack-config'); + +let TMP_HOME: string; +const ORIGINAL = { + HOME: process.env.HOME, + GSTACK_HOME: process.env.GSTACK_HOME, + USER: process.env.USER, +}; + +function runConfig(args: string[], extraEnv: Record = {}): { stdout: string; status: number; stderr: string } { + const result = spawnSync(CONFIG_BIN, args, { + encoding: 'utf-8', + env: { + ...process.env, + ...extraEnv, + }, + timeout: 5000, + }); + return { stdout: result.stdout || '', status: result.status ?? -1, stderr: result.stderr || '' }; +} + +beforeEach(() => { + TMP_HOME = mkdtempSync(join(tmpdir(), 'gstack-user-slug-test-')); + process.env.GSTACK_HOME = TMP_HOME; +}); + +afterEach(() => { + for (const [k, v] of Object.entries(ORIGINAL)) { + if (v !== undefined) process.env[k] = v; + else delete (process.env as Record)[k]; + } + try { rmSync(TMP_HOME, { recursive: true, force: true }); } catch { /* best effort */ } +}); + +describe('endpoint-hash subcommand', () => { + test('returns deterministic 8-char hex or literal "local"', () => { + const result = runConfig(['endpoint-hash'], { GSTACK_HOME: TMP_HOME }); + expect(result.status).toBe(0); + const out = result.stdout.trim(); + expect(out === 'local' || /^[a-f0-9]{8}$/.test(out) || /^[a-f0-9]{16}$/.test(out)).toBe(true); + }); +}); + +describe('resolve-user-slug fallback chain', () => { + test('uses $USER when set (layer 2)', () => { + const result = runConfig(['resolve-user-slug'], { GSTACK_HOME: TMP_HOME, USER: 'alice-test' }); + expect(result.status).toBe(0); + expect(result.stdout.trim()).toBe('alice-test'); + }); + + test('lowercases + dash-normalizes $USER', () => { + const result = runConfig(['resolve-user-slug'], { GSTACK_HOME: TMP_HOME, USER: 'Alice Test' }); + expect(result.status).toBe(0); + // Spaces become dashes, uppercase becomes lowercase + expect(result.stdout.trim()).toMatch(/^alice-test$/i); + }); + + test('falls through past empty $USER to git email or anonymous', () => { + const result = runConfig(['resolve-user-slug'], { GSTACK_HOME: TMP_HOME, USER: '' }); + expect(result.status).toBe(0); + const slug = result.stdout.trim(); + expect(slug.length).toBeGreaterThan(0); + // Should be either email- or anonymous- + expect(slug).toMatch(/^(email-|anonymous-)[a-f0-9]+$|^[a-zA-Z0-9-]+$/); + }); + + test('persists resolution to user_slug_at_ on first call', () => { + runConfig(['resolve-user-slug'], { GSTACK_HOME: TMP_HOME, USER: 'persisttest' }); + const configFile = join(TMP_HOME, 'config.yaml'); + expect(existsSync(configFile)).toBe(true); + const content = readFileSync(configFile, 'utf-8'); + expect(content).toMatch(/^user_slug_at_[a-f0-9]+:\s+persisttest/m); + }); + + test('subsequent calls return same slug (stable across sessions)', () => { + const first = runConfig(['resolve-user-slug'], { GSTACK_HOME: TMP_HOME, USER: 'stabletest' }); + const second = runConfig(['resolve-user-slug'], { GSTACK_HOME: TMP_HOME, USER: 'changed-after' }); + // Second call ignores new $USER because the slug was already persisted. + expect(first.stdout.trim()).toBe('stabletest'); + expect(second.stdout.trim()).toBe('stabletest'); + }); +}); + +describe('brain_trust_policy@ namespace', () => { + test('default value is "unset"', () => { + const result = runConfig(['get', 'brain_trust_policy@deadbeef'], { GSTACK_HOME: TMP_HOME }); + expect(result.status).toBe(0); + expect(result.stdout).toBe('unset'); + }); + + test('set + get roundtrip works', () => { + const setResult = runConfig(['set', 'brain_trust_policy@deadbeef', 'personal'], { GSTACK_HOME: TMP_HOME }); + expect(setResult.status).toBe(0); + const getResult = runConfig(['get', 'brain_trust_policy@deadbeef'], { GSTACK_HOME: TMP_HOME }); + expect(getResult.stdout).toBe('personal'); + }); + + test('invalid value falls back to unset with warning', () => { + const result = runConfig(['set', 'brain_trust_policy@deadbeef', 'invalid-value'], { GSTACK_HOME: TMP_HOME }); + expect(result.status).toBe(0); + expect(result.stderr).toContain('not recognized'); + const getResult = runConfig(['get', 'brain_trust_policy@deadbeef'], { GSTACK_HOME: TMP_HOME }); + expect(getResult.stdout).toBe('unset'); + }); + + test('shared value accepted', () => { + runConfig(['set', 'brain_trust_policy@deadbeef', 'shared'], { GSTACK_HOME: TMP_HOME }); + const getResult = runConfig(['get', 'brain_trust_policy@deadbeef'], { GSTACK_HOME: TMP_HOME }); + expect(getResult.stdout).toBe('shared'); + }); + + test('per-endpoint policies dont collide', () => { + runConfig(['set', 'brain_trust_policy@aaaaaaaa', 'personal'], { GSTACK_HOME: TMP_HOME }); + runConfig(['set', 'brain_trust_policy@bbbbbbbb', 'shared'], { GSTACK_HOME: TMP_HOME }); + const a = runConfig(['get', 'brain_trust_policy@aaaaaaaa'], { GSTACK_HOME: TMP_HOME }); + const b = runConfig(['get', 'brain_trust_policy@bbbbbbbb'], { GSTACK_HOME: TMP_HOME }); + expect(a.stdout).toBe('personal'); + expect(b.stdout).toBe('shared'); + }); +}); + +describe('key validation', () => { + test('rejects keys with disallowed characters', () => { + const result = runConfig(['get', 'bad-key'], { GSTACK_HOME: TMP_HOME }); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain('alphanumeric'); + }); + + test('accepts plain alphanumeric/underscore keys', () => { + const result = runConfig(['get', 'proactive'], { GSTACK_HOME: TMP_HOME }); + expect(result.status).toBe(0); + }); + + test('accepts @ suffix on key', () => { + const result = runConfig(['get', 'brain_trust_policy@abc123ff'], { GSTACK_HOME: TMP_HOME }); + expect(result.status).toBe(0); + }); +});