diff --git a/bin/gstack-artifacts-init b/bin/gstack-artifacts-init index 8f97c3305..c182077fe 100755 --- a/bin/gstack-artifacts-init +++ b/bin/gstack-artifacts-init @@ -232,6 +232,11 @@ retros/*.md developer-profile.json builder-journey.md builder-profile.jsonl +# Transcripts staged in remote-http MCP mode (per plan D11 split-engine). +# gstack-memory-ingest persists per-run dirs here when local gbrain import +# is skipped; brain admin pulls + indexes into the remote brain. +transcripts/run-*/*.md +transcripts/run-*/**/*.md # NOT synced (machine-local UX state): # projects/*/question-preferences.json (per-machine UX preferences) # projects/*/question-log.jsonl (audit/derivation log stays with preferences) @@ -251,7 +256,9 @@ cat > "$GSTACK_HOME/.brain-privacy-map.json" <<'EOF' {"pattern": "builder-journey.md", "class": "artifact"}, {"pattern": "projects/*/timeline.jsonl", "class": "behavioral"}, {"pattern": "developer-profile.json", "class": "behavioral"}, - {"pattern": "builder-profile.jsonl", "class": "behavioral"} + {"pattern": "builder-profile.jsonl", "class": "behavioral"}, + {"pattern": "transcripts/run-*/*.md", "class": "behavioral"}, + {"pattern": "transcripts/run-*/**/*.md", "class": "behavioral"} ] EOF diff --git a/gstack-upgrade/migrations/v1.34.0.0.sh b/gstack-upgrade/migrations/v1.34.0.0.sh new file mode 100755 index 000000000..ec196ac00 --- /dev/null +++ b/gstack-upgrade/migrations/v1.34.0.0.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# Migration: v1.34.0.0 — split-engine gbrain (remote MCP brain + optional +# local PGLite for code search per worktree). +# +# Per plan D5: prints a ONE-TIME discoverability notice for existing +# Path 4 users who don't yet have a local engine. They learn that +# symbol-aware code search (gbrain code-def / code-refs / code-callers) +# is now available via /setup-gbrain Step 4.5 if they want it. +# +# When to print the notice (state match — all conditions must hold): +# - ~/.claude.json declares mcpServers.gbrain.{type|transport} = http|sse|url +# OR mcpServers.gbrain.url is set (remote-http MCP active) +# - ~/.gbrain/config.json is absent (no local engine yet) +# - User has not previously opted out via: +# ~/.claude/skills/gstack/bin/gstack-config set local_code_index_offered true +# +# When silent: anything else (Path 1/2/3 users, anyone already on PGLite, +# anyone who opted out, anyone without remote-http MCP). +# +# Idempotency: writes a touchfile at ~/.gstack/.migrations/v1.34.0.0.done +# on completion. Re-running this script is silent if the touchfile exists, +# OR if local_code_index_offered=true. + +set -euo pipefail + +if [ -z "${HOME:-}" ]; then + echo " [v1.34.0.0] HOME is unset — skipping migration." >&2 + exit 0 +fi + +GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}" +MIGRATIONS_DIR="$GSTACK_HOME/.migrations" +DONE_TOUCH="$MIGRATIONS_DIR/v1.34.0.0.done" +CONFIG_BIN="$HOME/.claude/skills/gstack/bin/gstack-config" +CLAUDE_JSON="$HOME/.claude.json" +GBRAIN_CONFIG="$HOME/.gbrain/config.json" + +mkdir -p "$MIGRATIONS_DIR" + +# Idempotency: already-ran skips silently. +if [ -f "$DONE_TOUCH" ]; then + exit 0 +fi + +# User opt-out skips silently AND records done. +if [ -x "$CONFIG_BIN" ]; then + if [ "$("$CONFIG_BIN" get local_code_index_offered 2>/dev/null)" = "true" ]; then + touch "$DONE_TOUCH" + exit 0 + fi +fi + +# State match: remote-http MCP active? +is_remote_http_mcp() { + [ -f "$CLAUDE_JSON" ] || return 1 + command -v jq >/dev/null 2>&1 || return 1 + local mtype murl + mtype=$(jq -r '.mcpServers.gbrain.type // .mcpServers.gbrain.transport // empty' "$CLAUDE_JSON" 2>/dev/null) + murl=$(jq -r '.mcpServers.gbrain.url // empty' "$CLAUDE_JSON" 2>/dev/null) + case "$mtype" in + url|http|sse) return 0 ;; + esac + [ -n "$murl" ] && return 0 + return 1 +} + +# State match: local engine absent? +is_local_engine_missing() { + [ ! -f "$GBRAIN_CONFIG" ] +} + +if is_remote_http_mcp && is_local_engine_missing; then + cat <<'NOTICE' + + ┌──────────────────────────────────────────────────────────────────┐ + │ gstack v1.34.0.0 — split-engine gbrain │ + │ │ + │ Symbol-aware code search is now available on this machine. │ + │ Your remote brain at gbrain MCP keeps working as today; you can │ + │ add a tiny local PGLite (~30s, no accounts) for `gbrain │ + │ code-def` / `code-refs` / `code-callers` queries per worktree. │ + │ │ + │ Run /setup-gbrain to opt in at Step 4.5. Or skip this notice │ + │ permanently: │ + │ gstack-config set local_code_index_offered true │ + └──────────────────────────────────────────────────────────────────┘ + +NOTICE +fi + +# Always touch done so we don't print again, regardless of state-match outcome. +touch "$DONE_TOUCH" diff --git a/test/gstack-upgrade-migration-v1_34_0_0.test.ts b/test/gstack-upgrade-migration-v1_34_0_0.test.ts new file mode 100644 index 000000000..b22d8e1de --- /dev/null +++ b/test/gstack-upgrade-migration-v1_34_0_0.test.ts @@ -0,0 +1,194 @@ +/** + * Unit tests for gstack-upgrade/migrations/v1.34.0.0.sh — split-engine notice. + * + * Per plan D5: print a one-time discoverability notice for existing Path 4 + * (remote-http MCP) users who don't yet have a local engine, so they + * find /setup-gbrain Step 4.5. Silent for everyone else. Idempotent. + * + * Test matrix (5 cases): + * 1. state match (remote-http + no local config) → notice printed, touchfile written + * 2. state no-match (no MCP) → silent, touchfile written + * 3. state no-match (local config present) → silent, touchfile written + * 4. opt-out via local_code_index_offered=true → silent, touchfile written + * 5. idempotency: re-run after match is silent → notice NOT re-printed + */ + +import { describe, it, expect } from "bun:test"; +import { + mkdtempSync, + mkdirSync, + writeFileSync, + existsSync, + rmSync, + chmodSync, +} from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { execFileSync, spawnSync } from "child_process"; + +const MIGRATION = join( + import.meta.dir, + "..", + "gstack-upgrade", + "migrations", + "v1.34.0.0.sh", +); + +interface MigEnv { + tmp: string; + home: string; + gstackHome: string; + doneTouch: string; + claudeJson: string; + gbrainConfig: string; + configBin: string; + cleanup: () => void; +} + +function makeEnv(opts: { + remoteHttpMcp?: boolean; + hasLocalConfig?: boolean; + optedOut?: boolean; +}): MigEnv { + const tmp = mkdtempSync(join(tmpdir(), "migration-v1340-")); + const home = join(tmp, "home"); + const gstackHome = join(home, ".gstack"); + const gbrainDir = join(home, ".gbrain"); + const claudeSkillsBin = join(home, ".claude", "skills", "gstack", "bin"); + const claudeJson = join(home, ".claude.json"); + const gbrainConfig = join(gbrainDir, "config.json"); + const configBin = join(claudeSkillsBin, "gstack-config"); + + mkdirSync(home, { recursive: true }); + mkdirSync(gstackHome, { recursive: true }); + mkdirSync(gbrainDir, { recursive: true }); + mkdirSync(claudeSkillsBin, { recursive: true }); + + if (opts.remoteHttpMcp) { + writeFileSync( + claudeJson, + JSON.stringify({ + mcpServers: { + gbrain: { type: "http", url: "https://wintermute.example/mcp" }, + }, + }), + ); + } else { + writeFileSync(claudeJson, JSON.stringify({ mcpServers: {} })); + } + + if (opts.hasLocalConfig) { + writeFileSync(gbrainConfig, JSON.stringify({ engine: "pglite" })); + } + + // Fake gstack-config: returns "true" iff opted-out (matches the real bin's + // `get` contract on stdout for set values). + const optedOutResponse = opts.optedOut ? "true" : "false"; + writeFileSync( + configBin, + `#!/bin/sh +if [ "$1" = "get" ] && [ "$2" = "local_code_index_offered" ]; then + echo "${optedOutResponse}" + exit 0 +fi +exit 0 +`, + ); + chmodSync(configBin, 0o755); + + return { + tmp, + home, + gstackHome, + doneTouch: join(gstackHome, ".migrations", "v1.34.0.0.done"), + claudeJson, + gbrainConfig, + configBin, + cleanup: () => rmSync(tmp, { recursive: true, force: true }), + }; +} + +function runMigration(env: MigEnv): { stdout: string; stderr: string; exitCode: number } { + const result = spawnSync("bash", [MIGRATION], { + encoding: "utf-8", + timeout: 5_000, + env: { + ...process.env, + HOME: env.home, + GSTACK_HOME: env.gstackHome, + // The script looks for gstack-config at $HOME/.claude/skills/gstack/bin + // which is already in env.home; nothing else needed. + }, + }); + return { + stdout: result.stdout || "", + stderr: result.stderr || "", + exitCode: result.status ?? 1, + }; +} + +describe("gstack-upgrade/migrations/v1.34.0.0.sh", () => { + it("STATE MATCH: remote-http MCP + no local config → notice printed, touchfile written", () => { + const env = makeEnv({ remoteHttpMcp: true, hasLocalConfig: false }); + try { + const r = runMigration(env); + expect(r.exitCode).toBe(0); + expect(r.stdout + r.stderr).toContain("split-engine"); + expect(r.stdout + r.stderr).toContain("/setup-gbrain"); + expect(existsSync(env.doneTouch)).toBe(true); + } finally { + env.cleanup(); + } + }); + + it("NO MATCH: no MCP at all → silent, touchfile written", () => { + const env = makeEnv({ remoteHttpMcp: false, hasLocalConfig: false }); + try { + const r = runMigration(env); + expect(r.exitCode).toBe(0); + expect(r.stdout + r.stderr).not.toContain("split-engine"); + expect(existsSync(env.doneTouch)).toBe(true); + } finally { + env.cleanup(); + } + }); + + it("NO MATCH: local config present → silent, touchfile written", () => { + const env = makeEnv({ remoteHttpMcp: true, hasLocalConfig: true }); + try { + const r = runMigration(env); + expect(r.exitCode).toBe(0); + expect(r.stdout + r.stderr).not.toContain("split-engine"); + expect(existsSync(env.doneTouch)).toBe(true); + } finally { + env.cleanup(); + } + }); + + it("OPT-OUT: local_code_index_offered=true → silent, touchfile written", () => { + const env = makeEnv({ remoteHttpMcp: true, hasLocalConfig: false, optedOut: true }); + try { + const r = runMigration(env); + expect(r.exitCode).toBe(0); + expect(r.stdout + r.stderr).not.toContain("split-engine"); + expect(existsSync(env.doneTouch)).toBe(true); + } finally { + env.cleanup(); + } + }); + + it("IDEMPOTENT: second run after match is silent (touchfile already present)", () => { + const env = makeEnv({ remoteHttpMcp: true, hasLocalConfig: false }); + try { + const first = runMigration(env); + expect(first.exitCode).toBe(0); + expect(first.stdout + first.stderr).toContain("split-engine"); + + const second = runMigration(env); + expect(second.exitCode).toBe(0); + expect(second.stdout + second.stderr).not.toContain("split-engine"); + } finally { + env.cleanup(); + } + }); +});