diff --git a/CHANGELOG.md b/CHANGELOG.md index 9212bdd0..ea5181f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,59 @@ # Changelog +## [1.12.0.0] - 2026-04-24 + +## **`/setup-gbrain` — any coding agent goes from zero to "gbrain is running, and I can call it" in under five minutes.** + +gstack v1.9.0.0 shipped `gbrain-sync`, which assumed a `gbrain` CLI was already installed. That was fine on Garry's machine (he'd manually cloned `~/git/gbrain`), broken for everyone else. This release closes the onboarding gap: one skill, three paths (local PGLite, existing Supabase URL, or Supabase auto-provision via the Management API), an MCP registration step for Claude Code, a per-remote trust triad (read-write / read-only / deny) so multi-client consultants don't mingle brains, and a reusable secret-sink test harness other skills can import when they start handling secrets. + +### What shipped + +Six new `bin/` helpers and one new skill template. `bin/gstack-gbrain-repo-policy` stores per-remote ingest tiers at `~/.gstack/gbrain-repo-policy.json` with a `_schema_version: 2` field so future migrations are deterministic (the first one — legacy `allow` → `read-write` — already runs on first read of any pre-D3 file). `bin/gstack-gbrain-detect` emits the full state as JSON so the skill can skip steps that are already done. `bin/gstack-gbrain-install` probes `~/git/gbrain` and `~/gbrain` before cloning fresh (fixes the day-one dup-clone footgun on the author's own machine) and fails hard on PATH shadowing with a three-option remediation menu instead of warn-and-continue. `bin/gstack-gbrain-lib.sh` extracts the `read_secret_to_env` helper used for both PAT collection and pooler-URL paste — one canonical implementation of the stty-echo-off + SIGINT-restore + env-var-only pattern. `bin/gstack-gbrain-supabase-verify` rejects direct-connection URLs (IPv6-only, fails in most environments) with exit code 3 so the caller's retry UX is distinct from a generic format error. `bin/gstack-gbrain-supabase-provision` wraps the Management API — list-orgs, create, poll, pooler-url, list-orphans, delete-project — with full HTTP error coverage (401/403/402/409/429/5xx), exponential backoff, and `--cleanup-orphans` support for the rare case where someone kills setup mid-provision. + +The skill template itself threads these together into a single interactive flow. PAT collection shows the full scope disclosure verbatim before the read-s prompt, explains that the token grants access to every project in the user's Supabase account, and emits a revocation reminder at the end. Path 1's pooler-URL paste gets the same hygiene plus a redacted preview (host / port / database visible, password masked). Switching between engines wraps `gbrain migrate` in `timeout 180s` with an actionable message on deadlock. Concurrent-run protection via `mkdir ~/.gstack/.setup-gbrain.lock.d`. Telemetry records scenario, install result, MCP opt-in, trust tier — all enumerated categorical values, never free-form strings that could leak secrets. + +`/health` gets a new GBrain dimension (weight 10%, wrapped in `timeout 5s`) alongside type-check / lint / tests / dead-code / shell-linter. The dimension is omitted — not red — when gbrain isn't installed, so running `/health` on a non-gbrain machine doesn't penalize that choice. + +`test/helpers/secret-sink-harness.ts` is new infrastructure. Runs a subprocess with a seeded secret, captures stdout / stderr / files-under-HOME / telemetry-JSONL, and asserts the seed never appears in any channel via four match rules (exact + URL-decoded + first-12-char prefix + base64). Seven positive-control tests prove the harness catches leaks in every covered channel; four negative controls run real setup-gbrain bins with seeded secrets and confirm nothing escapes. Any future skill that handles secrets can import `runWithSecretSink` and run the same pattern. + +### The numbers that matter + +Source: `bun test` against Slices 1–7's five new test files. + +| Suite | Tests | Time | +|---|---|---| +| `gbrain-repo-policy.test.ts` | 24 | ~1.2s | +| `gbrain-detect-install.test.ts` | 15 | ~1.0s | +| `gbrain-lib-verify.test.ts` | 22 | ~0.2s | +| `gbrain-supabase-provision.test.ts` | 28 | ~13.8s | +| `secret-sink-harness.test.ts` | 11 | ~7.0s | +| **Total** | **100** | **~23s** | + +Every HTTP error path for the Supabase Management API is covered by a mock-server fixture. Every secret-bearing bin is exercised with a distinctive seed through the leak harness. + +### What this means for Claude Code users + +Previously: install gbrain manually, hope nothing was shadowing on PATH, paste the pooler URL into an echoing prompt, figure out MCP registration yourself. Now: one command, three paths, PAT-handled-correctly auto-provision, MCP registered for Claude Code automatically, trust tiers for multi-client work, leak-tested end-to-end. Run `/setup-gbrain`. + +### Itemized changes + +#### Added +- `/setup-gbrain` skill (`setup-gbrain/SKILL.md.tmpl`) — full onboarding flow with path selection, PAT-scoped disclosure, redacted URL preview, concurrent-run lock, SIGINT recovery with `--resume-provision`, and `--cleanup-orphans` subcommand. +- `bin/gstack-gbrain-repo-policy` — per-remote trust triad (read-write / read-only / deny), schema-versioned file format, atomic writes, corrupt-file quarantine. +- `bin/gstack-gbrain-detect` — JSON state reporter for skill branching. +- `bin/gstack-gbrain-install` — D5 detect-first installer, D19 PATH-shadow fail-hard validator, pinned gbrain commit. +- `bin/gstack-gbrain-lib.sh` — shared `read_secret_to_env` bash helper. +- `bin/gstack-gbrain-supabase-verify` — structural URL validator with distinct exit for direct-connection rejects. +- `bin/gstack-gbrain-supabase-provision` — Management API wrapper (list-orgs / create / wait / pooler-url / list-orphans / delete-project) with full HTTP error coverage and retry+backoff. +- `test/helpers/secret-sink-harness.ts` — reusable negative-space leak-testing harness. + +#### Changed +- `/health` skill adds a GBrain composite dimension (weight 10%, wrapped in `timeout 5s`). Existing category weights rebalanced to keep the composite score on the 0–10 scale; historical JSONL entries without a `gbrain` field read as `null` for trend comparison. + +#### For contributors +- Pre-Impl Gate 1 verified Supabase Management API shape before any code was written. Corrected two wrong endpoint assumptions (`POST /v1/projects` not `/v1/organizations/{ref}/projects`; `/config/database/pooler` not `/config/database`) and confirmed gbrain's `--non-interactive` + `GBRAIN_DATABASE_URL` env var are real. Documented in the plan file. +- Review discipline: CEO review + Codex outside voice + Eng review all passed in plan mode before any code landed (3 reviews, 21 D-decisions, 0 unresolved gaps). + ## [1.11.1.0] - 2026-04-23 ## **Plan mode stopped silently rubber-stamping your reviews. The forcing questions actually fire now.** diff --git a/CLAUDE.md b/CLAUDE.md index ca1c5b99..dfe9df23 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,6 +26,26 @@ bun run slop:diff # slop findings in files changed on this branch only `test:evals` requires `ANTHROPIC_API_KEY`. Codex E2E tests (`test/codex-e2e.test.ts`) use Codex's own auth from `~/.codex/` config — no `OPENAI_API_KEY` env var needed. + +**Where the keys live on this machine.** Conductor workspaces don't inherit the +user's interactive shell env, so `ANTHROPIC_API_KEY` and `OPENAI_API_KEY` aren't +in the default process env. Before running any paid eval / E2E, source them from +`~/.zshrc` (that's where Garry keeps them): + +```bash +bash -c ' + eval "$(grep -E "^export (ANTHROPIC_API_KEY|OPENAI_API_KEY)=" ~/.zshrc)" + export ANTHROPIC_API_KEY OPENAI_API_KEY + EVALS=1 EVALS_TIER=periodic bun test test/skill-e2e-.test.ts +' +``` + +Do not echo the key value anywhere (stdout, logs, shell history). The grep+eval +pattern keeps it in process env only. When passing to a test's Agent SDK, do NOT +pass `env: {...}` to `runAgentSdkTest` — the SDK's auth pipeline doesn't pick up +the key the same way when env is supplied as an object (confirmed failure mode). +Instead, mutate `process.env.ANTHROPIC_API_KEY` ambiently before the call and +restore in `finally`. E2E tests stream progress in real-time (tool-by-tool via `--output-format stream-json --verbose`). Results are persisted to `~/.gstack-dev/evals/` with auto-comparison against the previous run. diff --git a/README.md b/README.md index 1d63004d..3f58a054 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ Fork it. Improve it. Make it yours. And if you want to hate on free open source Open Claude Code and paste this. Claude does the rest. -> Install gstack: run **`git clone --single-branch --depth 1 https://github.com/garrytan/gstack.git ~/.claude/skills/gstack && cd ~/.claude/skills/gstack && ./setup`** then add a "gstack" section to CLAUDE.md that says to use the /browse skill from gstack for all web browsing, never use mcp\_\_claude-in-chrome\_\_\* tools, and lists the available skills: /office-hours, /plan-ceo-review, /plan-eng-review, /plan-design-review, /design-consultation, /design-shotgun, /design-html, /review, /ship, /land-and-deploy, /canary, /benchmark, /browse, /connect-chrome, /qa, /qa-only, /design-review, /setup-browser-cookies, /setup-deploy, /retro, /investigate, /document-release, /codex, /cso, /autoplan, /plan-devex-review, /devex-review, /careful, /freeze, /guard, /unfreeze, /gstack-upgrade, /learn. Then ask the user if they also want to add gstack to the current project so teammates get it. +> Install gstack: run **`git clone --single-branch --depth 1 https://github.com/garrytan/gstack.git ~/.claude/skills/gstack && cd ~/.claude/skills/gstack && ./setup`** then add a "gstack" section to CLAUDE.md that says to use the /browse skill from gstack for all web browsing, never use mcp\_\_claude-in-chrome\_\_\* tools, and lists the available skills: /office-hours, /plan-ceo-review, /plan-eng-review, /plan-design-review, /design-consultation, /design-shotgun, /design-html, /review, /ship, /land-and-deploy, /canary, /benchmark, /browse, /connect-chrome, /qa, /qa-only, /design-review, /setup-browser-cookies, /setup-deploy, /setup-gbrain, /retro, /investigate, /document-release, /codex, /cso, /autoplan, /plan-devex-review, /devex-review, /careful, /freeze, /guard, /unfreeze, /gstack-upgrade, /learn. Then ask the user if they also want to add gstack to the current project so teammates get it. ### Step 2: Team mode — auto-update for shared repos (recommended) @@ -225,6 +225,7 @@ Each skill feeds into the next. `/office-hours` writes a design doc that `/plan- | `/unfreeze` | **Unlock** — remove the `/freeze` boundary. | | `/open-gstack-browser` | **GStack Browser** — launch GStack Browser with sidebar, anti-bot stealth, auto model routing (Sonnet for actions, Opus for analysis), one-click cookie import, and Claude Code integration. Clean up pages, take smart screenshots, edit CSS, and pass info back to your terminal. | | `/setup-deploy` | **Deploy Configurator** — one-time setup for `/land-and-deploy`. Detects your platform, production URL, and deploy commands. | +| `/setup-gbrain` | **GBrain Onboarding** — from zero to running gbrain in under 5 minutes. PGLite local, Supabase existing URL, or auto-provision a new Supabase project via Management API. MCP registration for Claude Code + per-repo trust triad (read-write/read-only/deny). [Full guide](USING_GBRAIN_WITH_GSTACK.md). | | `/gstack-upgrade` | **Self-Updater** — upgrade gstack to latest. Detects global vs vendored install, syncs both, shows what changed. | ### New binaries (v0.19) @@ -359,34 +360,39 @@ I open sourced how I build software. You can fork it and make it your own. > Come work at YC — [ycombinator.com/software](https://ycombinator.com/software) > Extremely competitive salary and equity. San Francisco, Dogpatch District. -## Cross-machine memory with GBrain sync +## GBrain — persistent knowledge for your coding agent -gstack accumulates a lot of useful state on your laptop: learnings, CEO -plans, design docs, retros, developer profile. Today, all of that dies when -you switch machines. **GBrain sync** optionally pushes a curated, secret-scanned -subset to a private git repo so your memory follows you, and (if you use -GBrain) becomes indexable there. +[GBrain](https://github.com/garrytan/gbrain) is a persistent knowledge base for AI agents — think of it as the memory your agent actually keeps between sessions. GStack gives you a one-command path from zero to "it's running, my agent can call it." -One command to turn it on: +```bash +/setup-gbrain +``` + +Three paths, pick one: + +- **Supabase, existing URL** — your cloud agent already provisioned a brain; paste the Session Pooler URL, now this laptop uses the same data. +- **Supabase, auto-provision** — paste a Supabase Personal Access Token; the skill creates a new project, polls to healthy, fetches the pooler URL, hands it to `gbrain init`. ~90 seconds end-to-end. +- **PGLite local** — zero accounts, zero network, ~30 seconds. Isolated brain on this Mac only. Great for try-first; migrate to Supabase later with `/setup-gbrain --switch`. + +After init, the skill offers to register gbrain as an MCP server for Claude Code (`claude mcp add gbrain -- gbrain serve`) so `gbrain search`, `gbrain put_page`, etc. show up as first-class typed tools — not bash shell-outs. + +**Per-remote trust policy.** Each repo on your machine gets one of three tiers: + +- `read-write` — agent can search the brain AND write new pages back from this repo +- `read-only` — agent can search but never writes (best for multi-client consultants: search the shared brain, don't contaminate it with Client A's work while in Client B's repo) +- `deny` — no gbrain interaction at all + +The skill asks once per repo. The decision is sticky across worktrees and branches of the same remote. + +**GStack memory sync (different feature, same private-repo infra).** Optionally pushes your gstack state (learnings, CEO plans, design docs, retros, developer profile) to a private git repo so your memory follows you across machines, with a one-time privacy prompt (everything allowlisted / artifacts only / off) and a defense-in-depth secret scanner that blocks AWS keys, tokens, PEM blocks, and JWTs before they leave your machine. ```bash gstack-brain-init ``` -That creates a private GitHub repo (or any git remote you prefer — -GitLab, Gitea, self-hosted). Every skill run syncs the queue at its -start and end boundaries. No daemon, no background process. A one-time -privacy prompt asks how much you want to share (everything allowlisted / -artifacts only / off). Secret-shaped content (AWS keys, GitHub tokens, -PEM blocks, JWTs, etc.) is blocked from sync before it leaves your -machine. +**Full monty — every scenario, every flag, every bin helper, every troubleshooting step:** [USING_GBRAIN_WITH_GSTACK.md](USING_GBRAIN_WITH_GSTACK.md) -New machine? Copy `~/.gstack-brain-remote.txt` over, run -`gstack-brain-restore`, and yesterday's learnings surface on today's -laptop. - -Full guide: [docs/gbrain-sync.md](docs/gbrain-sync.md) • -Error index: [docs/gbrain-sync-errors.md](docs/gbrain-sync-errors.md) +Other references: [docs/gbrain-sync.md](docs/gbrain-sync.md) (sync-specific guide) • [docs/gbrain-sync-errors.md](docs/gbrain-sync-errors.md) (error index) ## Docs @@ -394,6 +400,7 @@ Error index: [docs/gbrain-sync-errors.md](docs/gbrain-sync-errors.md) |-----|---------------| | [Skill Deep Dives](docs/skills.md) | Philosophy, examples, and workflow for every skill (includes Greptile integration) | | [Builder Ethos](ETHOS.md) | Builder philosophy: Boil the Lake, Search Before Building, three layers of knowledge | +| [Using GBrain with GStack](USING_GBRAIN_WITH_GSTACK.md) | Every path, flag, bin helper, and troubleshooting step for `/setup-gbrain` | | [GBrain Sync](docs/gbrain-sync.md) | Cross-machine memory setup, privacy modes, troubleshooting | | [Architecture](ARCHITECTURE.md) | Design decisions and system internals | | [Browser Reference](BROWSER.md) | Full command reference for `/browse` | @@ -438,8 +445,8 @@ Use /browse from gstack for all web browsing. Never use mcp__claude-in-chrome__* Available skills: /office-hours, /plan-ceo-review, /plan-eng-review, /plan-design-review, /design-consultation, /design-shotgun, /design-html, /review, /ship, /land-and-deploy, /canary, /benchmark, /browse, /open-gstack-browser, /qa, /qa-only, /design-review, -/setup-browser-cookies, /setup-deploy, /retro, /investigate, /document-release, /codex, -/cso, /autoplan, /pair-agent, /careful, /freeze, /guard, /unfreeze, /gstack-upgrade, /learn. +/setup-browser-cookies, /setup-deploy, /setup-gbrain, /retro, /investigate, /document-release, +/codex, /cso, /autoplan, /pair-agent, /careful, /freeze, /guard, /unfreeze, /gstack-upgrade, /learn. ``` ## License diff --git a/TODOS.md b/TODOS.md index 5264574c..27e7896e 100644 --- a/TODOS.md +++ b/TODOS.md @@ -2,6 +2,21 @@ ## Testing +### Pre-existing test failures surfaced during v1.12.0.0 ship + +**What:** Two remaining test failures on bare main that have been shipping as-is for multiple versions. (The bearer-json secret-scan regression flagged here originally was a real leak path and has been fixed in this PR — see Completed section below.) + +1. `gstack-config gbrain keys > GSTACK_HOME overrides real config dir` (`test/brain-sync.test.ts:104`) — the GSTACK_HOME env override leaks into the real `~/.gstack/config.yaml`. Test asserts real config does NOT contain `gbrain_sync_mode: full` but it does. Either the test environment isn't isolated correctly or `bin/gstack-config` is writing to both locations. +2. `Opus 4.7 overlay — pacing directive > keeps Fan out / Effort-match / Literal interpretation nudges` (`test/model-overlay-opus-4-7.test.ts:87`) — v1.10.1.0 (#1166) removed the "Fan out explicitly" nudge from the overlay but the assertion was never updated. Either the nudge should come back (intentional removal reverted) or the test should be updated to match the new expected content. + +**Why:** Both have been green-washing through recent `/ship` runs via "pre-existing test failures skipped: ." #1 signals a real config isolation bug; #2 is a stale assertion since the overlay intentionally removed that nudge. + +**Priority:** P0 (both) + +**Effort:** S each. #1 likely a test harness fix in `test/brain-sync.test.ts`'s setup hook. #2 is a one-line test update OR a revert of #1166. + +--- + ### `security-bench-haiku-responses.json` is 27MB, violates the 2MB tracked-file gate **What:** `browse/test/fixtures/security-bench-haiku-responses.json` landed on main at v1.6.4.0 (PR #1135) at 27MB. The `no compiled binaries in git > git tracks no files larger than 2MB` gate in `test/skill-validation.test.ts:1623` fails on main and on every feature branch that merges main afterward. @@ -1304,6 +1319,17 @@ Shipped in v0.6.5. TemplateContext in gen-skill-docs.ts bakes skill name into pr ## Completed +### Bearer-token secret-scan regression fixed + E2E coverage added for privacy gate + gh auto-create (v1.12.0.0) + +- **Fixed the `bearer-token-json` regression in `bin/gstack-brain-sync`** — the value charset `[A-Za-z0-9_./+=-]{16,}` didn't permit spaces, so auth headers with the standard `Bearer ` form (literal space after the scheme name) slipped past the scanner. Added an optional `(Bearer |Basic |Token )?` prefix to the pattern. Validated against 5 positive cases (including the regression fixture) + 3 negative cases (short tokens, non-secret keys, random JSON). The 7-pattern secret scanner now passes all fixtures including bearer-json. +- **Added `test/gstack-brain-init-gh-mock.test.ts`** — 8 tests exercising the `gh` CLI auto-create path that previously had zero coverage. Stubs `gh` on PATH to record every call, asserts `gh repo create --private --description "..." --source ` fires with the computed `gstack-brain-` default name. Covers: happy path, fall-through-to-`gh repo view` when create hits already-exists, user-provided-URL-bypasses-gh, gh-not-on-path prompts for URL, gh-not-authed prompts for URL, idempotent `--remote` re-runs, conflicting-remote rejection. +- **Added `test/skill-e2e-brain-privacy-gate.test.ts`** — periodic-tier E2E (~$0.30-$0.50/run). Stages a fake `gbrain` on PATH + `gbrain_sync_mode_prompted=false` in config, runs a real skill via `runAgentSdkTest`, intercepts tool-use via `canUseTool`, and asserts the preamble fires the 3-option privacy AskUserQuestion with canonical prose ("publish session memory" / "artifact" / "decline"). Second test asserts the gate is silent when `prompted=true` (idempotency-within-session). +- **Registered `brain-privacy-gate` in `test/helpers/touchfiles.ts`** (periodic tier) with dependency tracking on `scripts/resolvers/preamble/generate-brain-sync-block.ts`, `bin/gstack-brain-sync`, `bin/gstack-brain-init`, `bin/gstack-config`, and the Agent SDK runner. Diff-based selection will re-run the E2E whenever any of those change. + +**Completed:** v1.12.0.0 (2026-04-24) + +--- + ### Overlay efficacy harness + Opus 4.7 fanout nudge removal (v1.10.1.0) - Built `test/skill-e2e-overlay-harness.test.ts`, a parametric periodic-tier eval that drives `@anthropic-ai/claude-agent-sdk` and measures first-turn fanout rate (overlay-ON vs overlay-OFF) across registered fixtures - Measured the original "Fan out explicitly" overlay nudge: baseline Opus 4.7 = 70% first-turn fanout on toy prompt, with our nudge = 10%, with Anthropic's own canonical `` text = 0% diff --git a/USING_GBRAIN_WITH_GSTACK.md b/USING_GBRAIN_WITH_GSTACK.md new file mode 100644 index 00000000..f0dfb14c --- /dev/null +++ b/USING_GBRAIN_WITH_GSTACK.md @@ -0,0 +1,291 @@ +# Using GBrain with GStack + +Your coding agent, with a memory it actually keeps. + +[GBrain](https://github.com/garrytan/gbrain) is a persistent knowledge base designed for AI agents. It stores what your agent learns, what you've decided, what worked and what didn't, and lets the agent search all of it on demand. GStack gives you a one-command path from zero to "gbrain is running, and my agent can call it" — with paths for try-it-local, share-with-your-team, and everything between. + +This is the full monty: every scenario, every flag, every helper bin, every troubleshooting step. For the quick pitch, see the [README's GBrain section](README.md#gbrain--persistent-knowledge-for-your-coding-agent). For error codes and sync-specific issues, see [docs/gbrain-sync.md](docs/gbrain-sync.md). + +--- + +## The one-command install + +```bash +/setup-gbrain +``` + +That's it. The skill detects your current state, asks three questions at most, and walks you through install, init, MCP registration for Claude Code, and per-repo trust policy. On a clean Mac with nothing installed it finishes in under five minutes. On a Mac where something's already set up it takes seconds (it detects the existing state and skips done work). + +## The three paths + +You pick one when the skill asks "Where should your brain live?" + +### Path 1: Supabase, you already have a connection string + +Best for: you (or a teammate's cloud agent) already provisioned a Supabase brain and you want this local machine to use the same data. + +**What happens:** Paste the Session Pooler URL (Settings → Database → Connection Pooler → Session → copy URI, port 6543). The skill reads it with echo off, shows you a redacted preview (`aws-0-us-east-1.pooler.supabase.com:6543/postgres` — host visible, password masked), hands it to `gbrain init` via the `GBRAIN_DATABASE_URL` environment variable, and the URL is never written to argv or your shell history. + +**Trust warning:** Pasting this URL gives your local Claude Code full read/write access to every page in the shared brain. If that's not the trust level you want, pick PGLite local (Path 3) instead and accept the brains are disjoint. + +### Path 2a: Supabase, auto-provision a new project + +Best for: fresh Supabase account, you want a clean new project with zero clicking. + +**What happens:** You paste a Supabase Personal Access Token (PAT). The skill shows you the scope disclosure first — *the token grants full access to every project in your Supabase account, not just the one we're about to create*. It lists your organizations, asks which one and which region (default `us-east-1`), generates a database password, calls `POST /v1/projects`, polls `GET /v1/projects/{ref}` every 5 seconds until the project is `ACTIVE_HEALTHY` (180s timeout), fetches the pooler URL, hands it to `gbrain init`. End-to-end: ~90 seconds. + +At the end: explicit reminder to revoke the PAT at https://supabase.com/dashboard/account/tokens. The skill already discarded it from memory. + +**If you Ctrl-C mid-provision:** The SIGINT trap prints your in-flight project ref + a resume command. You can delete the orphan at the Supabase dashboard, or run `/setup-gbrain --resume-provision ` to pick up where you left off. + +### Path 2b: Supabase, create manually + +Best for: you'd rather click through supabase.com yourself than paste a PAT. + +**What happens:** The skill walks you through the four manual steps (signup → new project → wait ~2 min → copy Session Pooler URL), then takes over from Path 1's paste step. Same security treatment as Path 1. + +### Path 3: PGLite local + +Best for: try-it-first, no account, no cloud, no sharing. Or a dedicated "this Mac's brain" that stays isolated from any cloud agent. + +**What happens:** `gbrain init --pglite`. Brain lives at `~/.gbrain/brain.pglite`. No network calls. Done in 30 seconds. + +This is the best first choice if you just want to see what gbrain feels like before committing to cloud. You can always migrate later with `/setup-gbrain --switch`. + +## MCP registration for Claude Code + +By default the skill asks "Give Claude Code a typed tool surface for gbrain?" If you say yes, it runs: + +```bash +claude mcp add gbrain -- gbrain serve +``` + +That registers gbrain's stdio MCP server with Claude Code. Now `gbrain search`, `gbrain put_page`, `gbrain get_page`, etc. show up as first-class tools in every session, not bash shell-outs. + +**If `claude` is not on PATH**, the skill skips MCP registration gracefully with a manual-register hint. The CLI resolver still works from any skill that shells out to `gbrain` — MCP is an upgrade, not a prerequisite. + +**Other local agents** (Cursor, Codex CLI, etc.) need their own MCP registration. The skill is Claude-Code-targeted for v1; other hosts can register `gbrain serve` manually in their own MCP config. + +## Per-remote trust policy (the triad) + +Every repo on your machine gets a policy decision: **read-write**, **read-only**, or **deny**. + +- **read-write** — your agent can `gbrain search` from this repo's context AND write new pages back to the brain. Default for your own projects. +- **read-only** — your agent can search the brain but never writes new pages from this repo's sessions. Ideal for multi-client consultants: search the shared brain, don't contaminate it with Client A's code while you're in Client B's repo. +- **deny** — no gbrain interaction at all. The repo is invisible to gbrain tooling. + +The skill asks once per repo the first time you run a gstack skill there. After that the decision is sticky — every worktree + branch of the same git remote shares the same policy, so you set it once and it follows you. + +SSH and HTTPS remote variants collapse to the same key: `https://github.com/foo/bar.git` and `git@github.com:foo/bar.git` are the same repo. + +**To change a policy:** + +```bash +/setup-gbrain --repo # re-prompt for this repo only + +# Or directly: +~/.claude/skills/gstack/bin/gstack-gbrain-repo-policy set "github.com/foo/bar" read-only +``` + +**To see every policy:** + +```bash +~/.claude/skills/gstack/bin/gstack-gbrain-repo-policy list +``` + +Storage: `~/.gstack/gbrain-repo-policy.json`, mode 0600, schema-versioned so future migrations stay deterministic. + +## Switching engines later + +Picked PGLite and now want to join a team brain? One command: + +```bash +/setup-gbrain --switch +``` + +The skill runs `gbrain migrate --to supabase --url "$URL"` wrapped in `timeout 180s`. Migration is bidirectional (Supabase → PGLite also works) and lossless — pages, chunks, embeddings, links, tags, and timeline all copy. Your original brain is preserved as a backup. + +**If migration hangs:** another gstack session may be holding a lock on the source brain. The timeout fires at 3 minutes with an actionable message. Close other workspaces and re-run. + +## GStack memory sync (a separate concern) + +This is different from gbrain itself. Your gstack state (`~/.gstack/` — learnings, plans, retros, timeline, developer profile) is machine-local by default. "GStack memory sync" optionally pushes a curated, secret-scanned subset to a private git repo so your memory follows you across machines — and, if you're running gbrain, that git repo becomes indexable there too. + +Turn it on with: + +```bash +gstack-brain-init +``` + +You'll get a one-time privacy prompt: **everything allowlisted** / **artifacts only** (plans, designs, retros, learnings — skip behavioral data like timelines) / **off**. Every skill run syncs the queue at start and end — no daemon, no background process. + +Secret-shaped content (AWS keys, GitHub tokens, PEM blocks, JWTs, bearer tokens) is blocked from sync before it leaves your machine. + +**On a new machine:** Copy `~/.gstack-brain-remote.txt` over, run `gstack-brain-restore`, and yesterday's learnings surface on today's laptop. + +Full guide: [docs/gbrain-sync.md](docs/gbrain-sync.md). Error index: [docs/gbrain-sync-errors.md](docs/gbrain-sync-errors.md). + +`/setup-gbrain` offers to wire this up for you at the end of initial setup — it's one more AskUserQuestion, and it integrates with the same private-repo infrastructure. + +## Cleanup orphan projects + +If you Ctrl-C'd mid-provision, tried three different names before settling on one, or otherwise accumulated gbrain-shaped Supabase projects you don't use, there's a subcommand for that: + +```bash +/setup-gbrain --cleanup-orphans +``` + +The skill re-collects a PAT (one-time, discarded after), lists every project in your Supabase account whose name starts with `gbrain` and whose ref doesn't match your active `~/.gbrain/config.json` pooler URL. For each orphan it asks per-project: *"Delete orphan project `` (``, created ``)?"* — no batching, no "delete all" shortcut. The active brain is never offered for deletion. + +## Command + flag reference + +### `/setup-gbrain` entry modes + +| Invocation | What it does | +|---|---| +| `/setup-gbrain` | Full flow: detect state, pick path, install, init, MCP, policy, optional memory-sync | +| `/setup-gbrain --repo` | Flip the per-remote trust policy for the current repo only | +| `/setup-gbrain --switch` | Migrate engine (PGLite ↔ Supabase) without re-running the other steps | +| `/setup-gbrain --resume-provision ` | Resume a path-2a auto-provision that was interrupted during polling | +| `/setup-gbrain --cleanup-orphans` | List + per-project delete of orphan Supabase projects | + +### Bin helpers (for scripting) + +| Bin | Purpose | +|---|---| +| `gstack-gbrain-detect` | Emit current state as JSON: gbrain on PATH, version, config engine, doctor status, sync mode | +| `gstack-gbrain-install` | Detect-first installer (probes `~/git/gbrain`, `~/gbrain`, then fresh clone). Has `--dry-run` and `--validate-only` flags. PATH-shadow check exits 3 with remediation menu. | +| `gstack-gbrain-lib.sh` | Sourced, not executed. Provides `read_secret_to_env VARNAME "prompt" [--echo-redacted ""]` | +| `gstack-gbrain-supabase-verify` | Structural URL check. Rejects direct-connection URLs (`db.*.supabase.co:5432`) with exit 3 | +| `gstack-gbrain-supabase-provision` | Management API wrapper. Subcommands: `list-orgs`, `create`, `wait`, `pooler-url`, `list-orphans`, `delete-project`. All require `SUPABASE_ACCESS_TOKEN` in env. `create` and `pooler-url` also require `DB_PASS`. `--json` mode available on every subcommand. | +| `gstack-gbrain-repo-policy` | Per-remote trust triad. Subcommands: `get`, `set`, `list`, `normalize` | + +### gbrain CLI (upstream tool) + +Gbrain itself ships with these that gstack wraps: + +| Command | Purpose | +|---|---| +| `gbrain init --pglite` | Initialize a local PGLite brain | +| `gbrain init --non-interactive` | Initialize via env (`GBRAIN_DATABASE_URL` or `DATABASE_URL`). Never pass a URL as argv — it'll leak to shell history. | +| `gbrain doctor --json` | Health check. Returns `{status: "ok"|"warnings"|"error", health_score: 0-100, checks: [...]}` | +| `gbrain migrate --to supabase --url ...` | Move a PGLite brain to Supabase (lossless, preserves source as backup) | +| `gbrain migrate --to pglite` | Reverse migration | +| `gbrain search "query"` | Search the brain | +| `gbrain put_page --title "..." --tags "a,b" <<<"content"` | Write a page | +| `gbrain get_page ""` | Fetch a page | +| `gbrain serve` | Start the MCP stdio server (used by `claude mcp add`) | + +### Config files + state + +| Path | What lives there | +|---|---| +| `~/.gbrain/config.json` | Engine (pglite/postgres), database URL or path, API keys. Mode 0600. Written by `gbrain init`. | +| `~/.gstack/gbrain-repo-policy.json` | Per-remote trust triad. Schema v2. Mode 0600. | +| `~/.gstack/.setup-gbrain.lock.d` | Concurrent-run lock (atomic mkdir). Released on normal exit + SIGINT. | +| `~/.gstack/.brain-queue.jsonl` | Pending sync entries for gstack memory sync | +| `~/.gstack/.brain-last-push` | Timestamp of last sync push (for `/health` scoring) | +| `~/.gstack-brain-remote.txt` | URL of your gstack memory sync remote (safe to copy between machines) | +| `~/.gstack/.setup-gbrain-inflight.json` | Reserved for future `--resume-provision` persisted state | + +### Environment variables + +| Var | Where it's read | What it does | +|---|---|---| +| `SUPABASE_ACCESS_TOKEN` | `gstack-gbrain-supabase-provision` | PAT for Management API calls. Discarded after each setup run. | +| `DB_PASS` | `gstack-gbrain-supabase-provision` (create, pooler-url) | Generated DB password. Never in argv. | +| `GBRAIN_DATABASE_URL` | `gbrain init`, `gbrain doctor`, etc. | Postgres connection string (Supabase pooler URL for us). Env takes precedence over `~/.gbrain/config.json`. | +| `DATABASE_URL` | `gbrain init` (fallback) | Same semantics as `GBRAIN_DATABASE_URL`; checked second. | +| `SUPABASE_API_BASE` | `gstack-gbrain-supabase-provision` | Override the Management API host. Used by tests to point at a mock server. | +| `GBRAIN_INSTALL_DIR` | `gstack-gbrain-install` | Override default install path (`~/gbrain`) | +| `GSTACK_HOME` | every bin helper | Override `~/.gstack` state dir. Heavy test use. | + +## Security model + +One rule for every secret this skill touches: **env var only, never argv, never logged, never written to disk by us.** The only persistent storage is gbrain's own `~/.gbrain/config.json` at mode 0600, which is gbrain's discipline, not ours. + +**Enforced in code:** + +- CI grep test in `test/skill-validation.test.ts` fails the build if `$SUPABASE_ACCESS_TOKEN` or `$GBRAIN_DATABASE_URL` appears in an argv position +- CI grep test fails if `--insecure`, `-k`, or `NODE_TLS_REJECT_UNAUTHORIZED=0` appear in `bin/gstack-gbrain-supabase-provision` +- `set +x` at the top of the provision helper prevents debug tracing from leaking PAT +- Telemetry payload contains only enumerated categorical values (scenario, install result, MCP opt-in, trust tier) — never free-form strings that could contain secrets + +**Enforced via tests:** + +- `test/secret-sink-harness.test.ts` runs every secret-handling bin with a seeded secret and asserts the seed never appears in any captured channel (stdout, stderr, files under `$HOME`, telemetry JSONL). Four match rules per seed: exact, URL-decoded, first-12-char prefix, base64. +- Positive controls in the same test file deliberately leak seeds in every covered channel and assert the harness catches each one. Without the positive controls, a harness that silently under-reports would look identical to a working harness. + +**What you can still leak** (the honest limits of v1): + +- If you paste a secret into a normal chat message outside `read -s`, it's in the conversation transcript and any host-side logging +- The leak harness doesn't dump subprocess environment — a bin that `env >> ~/.log` would evade detection (no bin in v1 does this; grep tests prevent it) +- Your shell's own `HISTFILE` behavior is your shell's, not ours — we never pass secrets to argv so they don't land there via our code, but nothing stops you from pasting one into a raw `curl` command yourself + +## Troubleshooting + +### "PATH SHADOWING DETECTED" during install + +Another `gbrain` binary is earlier in PATH than the one the installer just linked. The installer's version check caught it. Fix one of: + +- `rm $(which gbrain)` if you don't need the other one +- Prepend `~/.bun/bin` to PATH in your shell rc so the linked binary wins +- Set `GBRAIN_INSTALL_DIR` to the shadowing binary's install directory and re-run + +Then re-run `/setup-gbrain`. + +### "rejected direct-connection URL" + +You pasted a `db..supabase.co:5432` URL. Those are IPv6-only and fail in most environments. Use the Session Pooler URL instead: Supabase dashboard → Settings → Database → Connection Pooler → **Session** → copy URI (port 6543). + +### Auto-provision times out at 180s + +The Supabase project is still initializing. Your ref was printed in the exit message. Wait a minute, then: + +```bash +/setup-gbrain --resume-provision +``` + +The skill re-collects a PAT, skips project creation, resumes polling. + +### "Another `/setup-gbrain` instance is running" + +You have a stale lock directory. If you're sure no other instance is actually running: + +```bash +rm -rf ~/.gstack/.setup-gbrain.lock.d +``` + +Then re-run. + +### "No cross-model tension" on policy file + +You edited `~/.gstack/gbrain-repo-policy.json` by hand with legacy `allow` values? No problem. On the next read, gstack auto-migrates `allow` → `read-write` and adds `_schema_version: 2`. One log line on stderr, idempotent, deterministic. + +### `gbrain doctor` says "warnings" + +`/health` treats that as yellow, not red. Check `gbrain doctor --json | jq .checks` to see which sub-checks are warning. Typical causes: resolver MECE overlap (skill names clashing) or DB connection not yet configured. + +### Switching PGLite → Supabase hangs + +Another gstack session in a sibling Conductor workspace may be holding a lock on your local PGLite file via its preamble's `gstack-brain-sync` call. Close other workspaces, re-run `/setup-gbrain --switch`. The timeout is bounded at 180s so you'll never actually wait forever. + +## Why this design + +**Why per-remote trust triad and not binary allow/deny?** Multi-client consultants need search without write-back. A freelance dev working on Client A in the morning and Client B in the afternoon can't let A's code insights leak into a brain Client B can search. Read-only solves that cleanly. + +**Why not bundle gbrain into gstack?** Gbrain is a separate, actively-developed project with its own release cadence, schema migrations, and MCP surface. Bundling would mean gstack has to gate gbrain updates, which slows gbrain improvements from reaching users. Separate-but-integrated lets each ship on its own cadence. + +**Why `gbrain init --non-interactive` via env var and not a flag?** Connection strings contain database passwords. Passing them as argv lands the password in `ps`, shell history, and process listings. Env-var handoff keeps the secret in process memory only. Gbrain supports both `GBRAIN_DATABASE_URL` and `DATABASE_URL`; we use the former to avoid collisions with non-gbrain tooling. + +**Why fail-hard on PATH shadowing instead of warn-and-continue?** A shadowed `gbrain` means every subsequent command calls a different binary than the one we just installed. That's a silent version-drift bug that surfaces as mysterious feature gaps weeks later. Setup skills have one job — set up a working environment. Refusing to install into a broken one is the setup-skill-correct behavior. + +**Why not auto-import every repo?** Privacy + noise. An auto-import preamble hook that ingests every repo you touch would: (a) leak work code into a shared brain without consent, and (b) clog search with throwaway repos. The per-remote policy makes ingestion an explicit, per-repo decision. `/setup-gbrain` doesn't install any auto-import hook today — but the policy store is forward-compatible for one later. + +## Related skills + next steps + +- `/health` — includes a GBrain dimension (doctor status, sync queue depth, last-push age) in its 0-10 composite score. The dimension is omitted when gbrain isn't installed; running `/health` on a non-gbrain machine doesn't penalize that choice. +- `/gstack-upgrade` — keeps gstack itself up to date. Does NOT upgrade gbrain independently. To bump gbrain, update `PINNED_COMMIT` in `bin/gstack-gbrain-install` and re-run `/setup-gbrain`. +- `/retro` — weekly retrospective pulls learnings and plans from your gbrain when memory sync is on, letting the retro reference cross-machine history. + +Run `/setup-gbrain` and see what sticks. diff --git a/VERSION b/VERSION index 1b915da2..2cf9e565 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.11.1.0 +1.12.0.0 diff --git a/bin/gstack-brain-sync b/bin/gstack-brain-sync index 4adb330f..b0a1ff93 100755 --- a/bin/gstack-brain-sync +++ b/bin/gstack-brain-sync @@ -88,7 +88,12 @@ patterns = [ ('pem-block', re.compile(r'-----BEGIN [A-Z ]{3,}-----')), ('jwt', re.compile(r'\\beyJ[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}\\b')), ('bearer-token-json', - re.compile(r'\"(authorization|api[_-]?key|apikey|token|secret|password)\"\\s*:\\s*\"[A-Za-z0-9_./+=-]{16,}\"', + # JSON-embedded auth headers. The optional Bearer/Basic/Token prefix + # matters: real auth values include a literal space after the scheme + # name, but the value charset below does not include spaces, so + # without the optional prefix every Bearer token in a JSON blob slips + # past the scanner. + re.compile(r'\"(authorization|api[_-]?key|apikey|token|secret|password)\"\\s*:\\s*\"(Bearer |Basic |Token )?[A-Za-z0-9_./+=-]{16,}\"', re.IGNORECASE)), ] text = sys.stdin.read() diff --git a/bin/gstack-gbrain-detect b/bin/gstack-gbrain-detect new file mode 100755 index 00000000..526ff82d --- /dev/null +++ b/bin/gstack-gbrain-detect @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +# gstack-gbrain-detect — emit current gbrain/gstack-brain state as JSON. +# +# Usage: +# gstack-gbrain-detect +# +# Output (always valid JSON, even when every check is false): +# { +# "gbrain_on_path": true|false, +# "gbrain_version": "0.18.2" | null, +# "gbrain_config_exists": true|false, +# "gbrain_engine": "pglite"|"postgres" | null, +# "gbrain_doctor_ok": true|false, +# "gstack_brain_sync_mode": "off"|"artifacts-only"|"full", +# "gstack_brain_git": true|false +# } +# +# The /setup-gbrain skill reads this once at startup to decide which path +# branches are live and which steps can be skipped. Never modifies state; +# pure introspection. Exits 0 unless `jq` is missing. +# +# Env: +# GSTACK_HOME — override ~/.gstack for gstack-brain-* state lookups. +set -euo pipefail + +STATE_DIR="${GSTACK_HOME:-$HOME/.gstack}" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +CONFIG_BIN="$SCRIPT_DIR/gstack-config" +GBRAIN_CONFIG="$HOME/.gbrain/config.json" + +die() { echo "gstack-gbrain-detect: $*" >&2; exit 2; } + +require_jq() { + command -v jq >/dev/null 2>&1 || die "jq is required. Install with: brew install jq" +} +require_jq + +# --- gbrain binary presence + version --- +gbrain_on_path=false +gbrain_version=null +if command -v gbrain >/dev/null 2>&1; then + gbrain_on_path=true + # Format versions as JSON strings; gbrain --version may print other chatter. + v=$(gbrain --version 2>/dev/null | head -1 | tr -d '[:space:]' || true) + if [ -n "$v" ]; then + gbrain_version=$(jq -Rn --arg v "$v" '$v') + fi +fi + +# --- gbrain config file --- +gbrain_config_exists=false +gbrain_engine=null +if [ -f "$GBRAIN_CONFIG" ]; then + gbrain_config_exists=true + # Engine is defensively parsed; an invalid config returns null, not a crash. + engine_raw=$(jq -r '.engine // empty' "$GBRAIN_CONFIG" 2>/dev/null || true) + case "$engine_raw" in + pglite|postgres) gbrain_engine=$(jq -Rn --arg e "$engine_raw" '$e') ;; + esac +fi + +# --- gbrain doctor health --- +# Doctor is wrapped in `timeout 5s` to match the /health D6 pattern and avoid +# the detect step hanging the skill when gbrain is broken or its DB is +# unreachable. Any nonzero exit or non-"ok"/"warnings" status → false. +gbrain_doctor_ok=false +if [ "$gbrain_on_path" = "true" ]; then + # Use `timeout` if available; some minimal macs use gtimeout from coreutils. + timeout_bin="" + if command -v timeout >/dev/null 2>&1; then timeout_bin="timeout 5s" + elif command -v gtimeout >/dev/null 2>&1; then timeout_bin="gtimeout 5s" + fi + if doctor_json=$(eval "$timeout_bin gbrain doctor --json" 2>/dev/null); then + status=$(echo "$doctor_json" | jq -r '.status // empty' 2>/dev/null || true) + case "$status" in + ok|warnings) gbrain_doctor_ok=true ;; + esac + fi +fi + +# --- gstack-brain-sync state (memory sync, separate from gbrain itself) --- +gstack_brain_sync_mode="off" +if [ -x "$CONFIG_BIN" ]; then + mode=$("$CONFIG_BIN" get gbrain_sync_mode 2>/dev/null || true) + case "$mode" in + off|artifacts-only|full) gstack_brain_sync_mode="$mode" ;; + esac +fi + +gstack_brain_git=false +if [ -d "$STATE_DIR/.git" ]; then + gstack_brain_git=true +fi + +# Emit single-object JSON. +jq -n \ + --argjson on_path "$gbrain_on_path" \ + --argjson version "$gbrain_version" \ + --argjson config_exists "$gbrain_config_exists" \ + --argjson engine "$gbrain_engine" \ + --argjson doctor_ok "$gbrain_doctor_ok" \ + --arg sync_mode "$gstack_brain_sync_mode" \ + --argjson brain_git "$gstack_brain_git" \ + '{ + gbrain_on_path: $on_path, + gbrain_version: $version, + gbrain_config_exists: $config_exists, + gbrain_engine: $engine, + gbrain_doctor_ok: $doctor_ok, + gstack_brain_sync_mode: $sync_mode, + gstack_brain_git: $brain_git + }' diff --git a/bin/gstack-gbrain-install b/bin/gstack-gbrain-install new file mode 100755 index 00000000..c5bfa991 --- /dev/null +++ b/bin/gstack-gbrain-install @@ -0,0 +1,183 @@ +#!/usr/bin/env bash +# gstack-gbrain-install — install the gbrain CLI on a local Mac. +# +# Usage: +# gstack-gbrain-install [--install-dir ] [--pinned-commit ] [--dry-run] +# +# D5 detect-first: before cloning anywhere, probe likely pre-existing +# locations (~/git/gbrain and ~/gbrain) and reuse a working clone if one +# exists. Falls back to a fresh clone of the pinned commit at ~/gbrain +# (override with GBRAIN_INSTALL_DIR or --install-dir). +# +# D19 PATH-shadowing: after `bun link`, compare `gbrain --version` output +# to the install-dir's package.json version. On mismatch, abort with an +# actionable error listing every gbrain on PATH. Never "silently fixes" +# PATH; setup skills should refuse broken environments. +# +# Prerequisites (checked before doing anything): +# - bun (install: curl -fsSL https://bun.sh/install | bash) +# - git +# - network reachability to https://github.com +# +# The pinned commit is declared here rather than resolved dynamically so +# upgrades are explicit and reviewable. Update PINNED_COMMIT when gstack +# verifies compatibility with a new gbrain release. +# +# Env: +# GBRAIN_INSTALL_DIR — override default install path (~/gbrain) +# +# Exit codes: +# 0 — success (or --dry-run printed the plan) +# 2 — prerequisite missing or invalid argument +# 3 — post-install validation failed (PATH shadow, broken binary, etc.) +set -euo pipefail + +# --- defaults --- +PINNED_COMMIT="08b3698e90532b7b66c445e6b1d8cdfe71822802" # gbrain v0.18.2 +PINNED_TAG="v0.18.2" +GBRAIN_REPO_URL="https://github.com/garrytan/gbrain.git" +DEFAULT_INSTALL_DIR="${GBRAIN_INSTALL_DIR:-$HOME/gbrain}" +INSTALL_DIR="$DEFAULT_INSTALL_DIR" +DRY_RUN=false +VALIDATE_ONLY=false + +die() { echo "gstack-gbrain-install: $*" >&2; exit 2; } +fail() { echo "gstack-gbrain-install: $*" >&2; exit 3; } +log() { echo "gstack-gbrain-install: $*"; } + +# --- parse args --- +while [ $# -gt 0 ]; do + case "$1" in + --install-dir) INSTALL_DIR="$2"; shift 2 ;; + --pinned-commit) PINNED_COMMIT="$2"; PINNED_TAG=""; shift 2 ;; + --dry-run) DRY_RUN=true; shift ;; + --validate-only) VALIDATE_ONLY=true; shift ;; + --help|-h) sed -n '2,30p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) die "unknown flag: $1" ;; + esac +done + +# --- prerequisites --- +check_prereq() { + local bin="$1" + local hint="$2" + if ! command -v "$bin" >/dev/null 2>&1; then + fail "required tool '$bin' not found. $hint" + fi +} + +if ! $VALIDATE_ONLY; then + check_prereq bun "Install: curl -fsSL https://bun.sh/install | bash" + check_prereq git "Install: xcode-select --install (macOS) or your package manager" + + # GitHub reachability — fail fast if offline rather than hanging `git clone`. + # --max-time 10, --head (no body), quiet. Status code 200-4xx means we reached + # the server (even 404 is reachability proof). + if ! curl -s --head --max-time 10 https://github.com >/dev/null 2>&1; then + fail "cannot reach https://github.com. Check your network and try again." + fi +fi + +# --- D5 detect-first: probe common locations before cloning fresh --- +# Accept any directory that looks like a gbrain clone: has package.json +# with name "gbrain" and a `bin.gbrain` entry. Don't accept version mismatches +# here — we'll let bun link run and then D19-validate. +is_valid_clone() { + local dir="$1" + [ -d "$dir" ] || return 1 + [ -f "$dir/package.json" ] || return 1 + local name + name=$(jq -r '.name // empty' "$dir/package.json" 2>/dev/null || true) + [ "$name" = "gbrain" ] || return 1 + local bin + bin=$(jq -r '.bin.gbrain // empty' "$dir/package.json" 2>/dev/null || true) + [ -n "$bin" ] || return 1 + return 0 +} + +DETECTED_CLONE="" +if ! $VALIDATE_ONLY; then + for candidate in "$HOME/git/gbrain" "$HOME/gbrain" "$INSTALL_DIR"; do + if is_valid_clone "$candidate"; then + DETECTED_CLONE="$candidate" + break + fi + done +fi + +if $VALIDATE_ONLY; then + log "validate-only mode: skipping detect + clone + install + link" +elif [ -n "$DETECTED_CLONE" ]; then + log "detected existing gbrain clone at $DETECTED_CLONE — reusing" + INSTALL_DIR="$DETECTED_CLONE" +else + # Fresh clone path. + if $DRY_RUN; then + log "DRY RUN: would clone $GBRAIN_REPO_URL @ $PINNED_COMMIT → $INSTALL_DIR" + exit 0 + fi + if [ -d "$INSTALL_DIR" ]; then + fail "install dir $INSTALL_DIR exists but is not a valid gbrain clone. Remove it or pass --install-dir ." + fi + log "cloning $GBRAIN_REPO_URL → $INSTALL_DIR" + git clone --quiet "$GBRAIN_REPO_URL" "$INSTALL_DIR" + ( cd "$INSTALL_DIR" && git checkout --quiet "$PINNED_COMMIT" ) + log "pinned to $PINNED_COMMIT${PINNED_TAG:+ ($PINNED_TAG)}" +fi + +if $DRY_RUN; then + log "DRY RUN: would run bun install + bun link in $INSTALL_DIR" + exit 0 +fi + +# --- install + link --- +if ! $VALIDATE_ONLY; then + log "running bun install in $INSTALL_DIR" + ( cd "$INSTALL_DIR" && bun install --silent ) + log "running bun link in $INSTALL_DIR" + ( cd "$INSTALL_DIR" && bun link --silent ) +fi + +# --- D19 PATH-shadowing validation --- +# Read the version from the install-dir's package.json; compare to +# `gbrain --version`. If they disagree, PATH is returning a DIFFERENT +# gbrain than the one we just linked. Fail hard with remediation. +expected_version=$(jq -r '.version // empty' "$INSTALL_DIR/package.json" 2>/dev/null || true) +if [ -z "$expected_version" ]; then + fail "cannot read version from $INSTALL_DIR/package.json (install may be broken)" +fi + +if ! command -v gbrain >/dev/null 2>&1; then + fail "bun link completed but 'gbrain' is not on PATH. Ensure ~/.bun/bin is in your PATH." +fi + +actual_version=$(gbrain --version 2>/dev/null | head -1 | tr -d '[:space:]' || true) +if [ -z "$actual_version" ]; then + fail "gbrain is on PATH but 'gbrain --version' produced no output — the binary may be broken." +fi + +# Tolerate a leading "v" (gbrain may print either "0.18.2" or "v0.18.2"). +expected_norm="${expected_version#v}" +actual_norm="${actual_version#v}" + +if [ "$actual_norm" != "$expected_norm" ]; then + echo "" >&2 + echo "gstack-gbrain-install: PATH SHADOWING DETECTED" >&2 + echo "" >&2 + echo " We just linked gbrain $expected_version from $INSTALL_DIR," >&2 + echo " but PATH is returning gbrain $actual_version." >&2 + echo "" >&2 + echo " All gbrain binaries on PATH:" >&2 + type -a gbrain 2>&1 | sed 's/^/ /' >&2 || true + echo "" >&2 + echo " Fix one of the following, then re-run /setup-gbrain:" >&2 + echo " a) rm the shadowing binary: rm \$(which gbrain)" >&2 + echo " b) prepend ~/.bun/bin to PATH in your shell rc" >&2 + echo " c) point GBRAIN_INSTALL_DIR at the shadowing binary's install dir" >&2 + echo "" >&2 + exit 3 +fi + +log "installed gbrain $actual_version from $INSTALL_DIR" +echo "" +echo "Next: gbrain init --pglite (or run /setup-gbrain for the full setup flow)" diff --git a/bin/gstack-gbrain-lib.sh b/bin/gstack-gbrain-lib.sh new file mode 100644 index 00000000..7498e568 --- /dev/null +++ b/bin/gstack-gbrain-lib.sh @@ -0,0 +1,101 @@ +# gstack-gbrain-lib.sh — shared helpers for setup-gbrain bin scripts. +# +# This file is NOT executable; source it: +# +# . "$(dirname "$0")/gstack-gbrain-lib.sh" +# +# Provides: +# read_secret_to_env [--echo-redacted ] +# — Read a secret from stdin into the named env var without echoing +# to the terminal. On SIGINT/SIGTERM/EXIT, restores terminal echo so +# future keystrokes are visible. Optionally emits a redacted preview +# of what was read so the user can visually confirm they pasted the +# right thing. +# +# stdin handling: when stdin is a TTY, stty -echo suppresses echo +# while the user types. When stdin is piped (automated tests), the +# stty calls are skipped — piping into `read` is already invisible. +# +# Var name must match [A-Z_][A-Z0-9_]* to prevent injection via +# `read -r "$varname"` expansion. Invalid names abort. +# +# Exported after read so sub-processes inherit the secret. Caller +# is responsible for `unset ` when done. +# +# Load-bearing for D3-eng (shared secret helper across PAT + URL paste), +# D10 (env-var handoff, never argv), D11 (PAT scope disclosure + SIGINT +# restore), D16 (pooler URL paste hygiene with redacted preview). + +# _gstack_gbrain_validate_varname — returns 0 if usable, 2 otherwise. +_gstack_gbrain_validate_varname() { + local name="$1" + case "$name" in + [A-Z_][A-Z0-9_]*) return 0 ;; + *) return 2 ;; + esac +} + +read_secret_to_env() { + local varname="" prompt="" redact_expr="" + # Parse leading positional args (varname, prompt), then optional flags. + if [ $# -lt 2 ]; then + echo "read_secret_to_env: usage: read_secret_to_env [--echo-redacted ]" >&2 + return 2 + fi + varname="$1"; shift + prompt="$1"; shift + while [ $# -gt 0 ]; do + case "$1" in + --echo-redacted) redact_expr="$2"; shift 2 ;; + *) echo "read_secret_to_env: unknown flag: $1" >&2; return 2 ;; + esac + done + + if ! _gstack_gbrain_validate_varname "$varname"; then + echo "read_secret_to_env: invalid var name '$varname' (must match [A-Z_][A-Z0-9_]*)" >&2 + return 2 + fi + + # stty manipulation only makes sense when stdin is a terminal. In CI / + # test / piped contexts we skip it — piped input doesn't echo anyway. + local is_tty=false + if [ -t 0 ]; then is_tty=true; fi + + if $is_tty; then + # Save current stty state; restore on any exit path. + local saved_stty + saved_stty=$(stty -g 2>/dev/null || echo "") + # shellcheck disable=SC2064 + trap "stty '$saved_stty' 2>/dev/null; printf '\n' >&2" INT TERM EXIT + stty -echo 2>/dev/null || true + fi + + # Prompt on stderr so the caller can capture stdout cleanly. + printf '%s' "$prompt" >&2 + + # Read one line from stdin. `read -r` returns nonzero on EOF-without- + # newline but still populates `value` with whatever it saw — we want that + # content, so don't clear on failure. + local value="" + IFS= read -r value || true + + if $is_tty; then + stty "$saved_stty" 2>/dev/null || true + trap - INT TERM EXIT + printf '\n' >&2 + fi + + # Assign + export to the named variable. + printf -v "$varname" '%s' "$value" + # shellcheck disable=SC2163 + export "$varname" + + # Optional redacted preview after successful read. + if [ -n "$redact_expr" ] && [ -n "$value" ]; then + local preview + preview=$(printf '%s' "$value" | sed "$redact_expr" 2>/dev/null || true) + if [ -n "$preview" ]; then + printf 'Got: %s\n' "$preview" >&2 + fi + fi +} diff --git a/bin/gstack-gbrain-repo-policy b/bin/gstack-gbrain-repo-policy new file mode 100755 index 00000000..ba2f5a63 --- /dev/null +++ b/bin/gstack-gbrain-repo-policy @@ -0,0 +1,227 @@ +#!/usr/bin/env bash +# gstack-gbrain-repo-policy — per-remote trust tier for gbrain repo ingest. +# +# Usage: +# gstack-gbrain-repo-policy get [] +# Print the tier for the given remote, or the current repo's origin +# if no URL is passed. Exits 0 with one of: read-write, read-only, +# deny, unset. +# +# gstack-gbrain-repo-policy set +# Persist a tier for the given remote. Exits 0 on success. +# +# gstack-gbrain-repo-policy list +# Print every entry as "\t", sorted by key. +# +# gstack-gbrain-repo-policy normalize +# Print the normalized (canonical) key for a given remote URL. +# Use this when other skills or tests need the same collapsing logic. +# +# gstack-gbrain-repo-policy --help +# +# Storage: +# ~/.gstack/gbrain-repo-policy.json, mode 0600. +# +# File format: +# { +# "_schema_version": 2, +# "github.com/foo/bar": "read-write", +# "github.com/baz/qux": "deny" +# } +# +# Tier semantics: +# read-write — agent may search AND write new pages from this repo. +# read-only — agent may search but NEVER write pages from this repo. +# (Enforced at the caller level; this binary just stores the +# decision.) +# deny — no gbrain interaction at all. +# +# Legacy migration: +# On any read of a file missing `_schema_version` (or with version < 2), +# legacy `allow` values are atomically rewritten to `read-write`, and +# `_schema_version: 2` is added. Log line emitted on stderr when the +# migration actually changes anything. Idempotent: running twice is safe. +# +# Env: +# GSTACK_HOME — override ~/.gstack state directory (aligns with other +# gstack-* bins; used heavily in tests). +set -euo pipefail + +STATE_DIR="${GSTACK_HOME:-$HOME/.gstack}" +POLICY_FILE="$STATE_DIR/gbrain-repo-policy.json" +SCHEMA_VERSION=2 + +die() { echo "gstack-gbrain-repo-policy: $*" >&2; exit 2; } + +require_jq() { + if ! command -v jq >/dev/null 2>&1; then + die "jq is required. Install with: brew install jq" + fi +} + +# normalize — canonical form: lowercase host + path, no protocol, +# no userinfo, no trailing .git or /. SSH shorthand (git@host:path) collapses +# to the same key as https://host/path. +normalize() { + local url="$1" + [ -z "$url" ] && { echo ""; return 0; } + # Strip protocol:// + url="${url#*://}" + # Strip userinfo (git@, user:password@, etc.) — everything up to and + # including the first @ iff an @ appears before the first / or :. + case "$url" in + *@*) + local before_at="${url%%@*}" + case "$before_at" in + */*|*:*) : ;; # @ is in the path, not userinfo — leave it + *) url="${url#*@}" ;; + esac + ;; + esac + # SSH shorthand: github.com:foo/bar → github.com/foo/bar. Only when the + # hostname-part (before first /) contains a colon. sed is clearer than + # bash's `${var/:/\/}` which has tricky escaping. + local head="${url%%/*}" + case "$head" in + *:*) url=$(printf '%s' "$url" | sed 's|:|/|') ;; + esac + # Strip trailing .git + url="${url%.git}" + # Strip trailing / + url="${url%/}" + # Lowercase the whole thing. GitHub and most hosts are case-insensitive on + # paths anyway; collapsing avoids duplicate entries for "Foo/Bar" vs + # "foo/bar". + printf '%s\n' "$url" | tr '[:upper:]' '[:lower:]' +} + +# ensure_file — create the policy file if missing, migrate if legacy. +# Emits the migration log line on stderr exactly once per run when a +# migration actually rewrites values. +ensure_file() { + require_jq + mkdir -p "$STATE_DIR" + + if [ ! -f "$POLICY_FILE" ]; then + # Fresh file — just the schema version, no entries. + local tmp + tmp=$(mktemp "$POLICY_FILE.tmp.XXXXXX") + printf '{"_schema_version":%d}\n' "$SCHEMA_VERSION" > "$tmp" + mv "$tmp" "$POLICY_FILE" + chmod 0600 "$POLICY_FILE" + return 0 + fi + + # File exists — validate, migrate if needed. + local raw + if ! raw=$(cat "$POLICY_FILE" 2>/dev/null); then + die "Cannot read $POLICY_FILE" + fi + + # Corrupt JSON → quarantine and start fresh. + if ! echo "$raw" | jq empty 2>/dev/null; then + local ts + ts=$(date +%Y%m%d-%H%M%S) + local quarantine="$POLICY_FILE.corrupt-$ts" + mv "$POLICY_FILE" "$quarantine" + echo "gstack-gbrain-repo-policy: corrupt policy file quarantined to $quarantine; starting fresh" >&2 + local tmp + tmp=$(mktemp "$POLICY_FILE.tmp.XXXXXX") + printf '{"_schema_version":%d}\n' "$SCHEMA_VERSION" > "$tmp" + mv "$tmp" "$POLICY_FILE" + chmod 0600 "$POLICY_FILE" + return 0 + fi + + # Check schema version. + local version + version=$(echo "$raw" | jq -r '._schema_version // 0') + if [ "$version" -ge "$SCHEMA_VERSION" ]; then + return 0 + fi + + # Migrate: rename `allow` → `read-write`, add _schema_version. + local allow_count migrated + allow_count=$(echo "$raw" | jq '[to_entries[] | select(.key != "_schema_version" and .value == "allow")] | length') + migrated=$(echo "$raw" | jq --argjson v "$SCHEMA_VERSION" ' + (to_entries | map( + if .key == "_schema_version" then empty + elif .value == "allow" then .value = "read-write" + else . + end + ) | from_entries) + {_schema_version: $v} + ') + local tmp + tmp=$(mktemp "$POLICY_FILE.tmp.XXXXXX") + printf '%s\n' "$migrated" > "$tmp" + mv "$tmp" "$POLICY_FILE" + chmod 0600 "$POLICY_FILE" + if [ "$allow_count" -gt 0 ]; then + echo "[gstack-gbrain-repo-policy] Migrated $allow_count legacy allow entries to read-write" >&2 + fi +} + +cmd_get() { + local url="${1:-}" + if [ -z "$url" ]; then + url=$(git remote get-url origin 2>/dev/null || true) + if [ -z "$url" ]; then + echo "unset" + return 0 + fi + fi + local key + key=$(normalize "$url") + if [ -z "$key" ]; then + echo "unset" + return 0 + fi + ensure_file + jq -r --arg key "$key" '.[$key] // "unset"' "$POLICY_FILE" +} + +cmd_set() { + local url="${1:-}" + local tier="${2:-}" + [ -z "$url" ] && die "usage: set " + [ -z "$tier" ] && die "usage: set " + case "$tier" in + read-write|read-only|deny) ;; + *) die "invalid tier '$tier' (must be one of: read-write, read-only, deny)" ;; + esac + local key + key=$(normalize "$url") + [ -z "$key" ] && die "cannot normalize remote URL: $url" + ensure_file + local tmp + tmp=$(mktemp "$POLICY_FILE.tmp.XXXXXX") + jq --arg key "$key" --arg tier "$tier" '.[$key] = $tier' "$POLICY_FILE" > "$tmp" + mv "$tmp" "$POLICY_FILE" + chmod 0600 "$POLICY_FILE" + echo "Set $key → $tier" +} + +cmd_list() { + if [ ! -f "$POLICY_FILE" ]; then + # Nothing to list; don't create the file just for a read. + return 0 + fi + ensure_file + jq -r 'to_entries[] | select(.key != "_schema_version") | "\(.key)\t\(.value)"' "$POLICY_FILE" | sort +} + +cmd_normalize() { + local url="${1:-}" + [ -z "$url" ] && die "usage: normalize " + normalize "$url" +} + +case "${1:-}" in + get) shift; cmd_get "$@" ;; + set) shift; cmd_set "$@" ;; + list) shift; cmd_list "$@" ;; + normalize) shift; cmd_normalize "$@" ;; + --help|-h|help) sed -n '2,47p' "$0" | sed 's/^# \{0,1\}//' ;; + "") die "usage: gstack-gbrain-repo-policy {get|set|list|normalize|--help}" ;; + *) die "unknown subcommand: $1" ;; +esac diff --git a/bin/gstack-gbrain-supabase-provision b/bin/gstack-gbrain-supabase-provision new file mode 100755 index 00000000..3f3128e9 --- /dev/null +++ b/bin/gstack-gbrain-supabase-provision @@ -0,0 +1,447 @@ +#!/usr/bin/env bash +# gstack-gbrain-supabase-provision — Supabase Management API wrapper for +# /setup-gbrain path 2a (auto-provision). +# +# Subcommands: +# list-orgs +# GET /v1/organizations. Output: {"orgs": [{"slug","name"}, ...]} +# +# create +# POST /v1/projects with {name, db_pass, organization_slug, region}. +# db_pass must be in the DB_PASS env var (never argv — D8 grep test +# enforces this). Output: {"ref","name","region","organization_slug","status"}. +# +# NOTE: does NOT send a `plan` field. Per verified Supabase Management +# API OpenAPI, the `plan` field is now deprecated at the project level +# — subscription tier is an org-level decision (D17 updated). +# +# wait [--timeout ] +# Poll GET /v1/projects/{ref} every 5s until status=ACTIVE_HEALTHY, +# or fail on terminal states (INIT_FAILED, REMOVED). Default timeout +# 180s. Output on success: {"ref","status","elapsed_s"}. +# +# pooler-url +# GET /v1/projects/{ref}/config/database/pooler, construct the full +# Session Pooler URL using DB_PASS from env (the API response's +# connection_string is typically templated [PASSWORD] rather than the +# real value — we build from db_user/db_host/db_port/db_name instead). +# Output: {"ref","pooler_url"}. +# +# list-orphans [--name-prefix ] +# GET /v1/projects. Filter to projects whose name starts with --name-prefix +# (default "gbrain") AND whose ref does NOT match the one in the local +# active ~/.gbrain/config.json pooler URL. Those are the gbrain-shaped +# projects that aren't pointed at by a working local config — candidates +# for /setup-gbrain --cleanup-orphans. +# Output: {"active_ref","orphans":[{"ref","name","created_at","region"}, ...]}. +# +# delete-project +# DELETE /v1/projects/{ref}. Destructive, one-way — callers must +# double-confirm before invoking. This bin performs NO confirmation +# prompt; the skill's UI layer owns that responsibility. +# Output: {"deleted_ref"}. +# +# Secrets discipline (D8, D10, D11): +# - SUPABASE_ACCESS_TOKEN is read from env; never accepted as argv. +# - DB_PASS (for `create` and `pooler-url`) is read from env; never argv. +# - Forbidden strings (enforced by skill-validation grep test): +# --insecure, -k (curl), NODE_TLS_REJECT_UNAUTHORIZED +# - `set +x` default — debug mode requires explicit opt-in around +# non-secret lines. +# +# Env: +# SUPABASE_ACCESS_TOKEN — PAT for auth (required on all subcommands) +# DB_PASS — database password (required for create + pooler-url) +# SUPABASE_API_BASE — override the API host (tests point this at a +# local mock server). Default: https://api.supabase.com +# +# Exit codes: +# 0 — success +# 2 — usage / invalid input +# 3 — auth failure (401/403) — retry with fresh PAT +# 4 — quota / billing (402) — user action needed +# 5 — conflict (409) — duplicate name, user action needed +# 6 — timeout (wait subcommand hit its deadline) +# 7 — terminal failure state from Supabase (INIT_FAILED, REMOVED) +# 8 — network / 5xx after retries +set +x # Defensive: never trace secrets in this helper. +set -euo pipefail + +SUPABASE_API_BASE="${SUPABASE_API_BASE:-https://api.supabase.com}" +API_VERSION="v1" +DEFAULT_WAIT_TIMEOUT=180 +POLL_INTERVAL=5 +CURL_TIMEOUT=30 + +die() { echo "gstack-gbrain-supabase-provision: $*" >&2; exit 2; } +die_auth() { echo "gstack-gbrain-supabase-provision: $*" >&2; exit 3; } +die_quota(){ echo "gstack-gbrain-supabase-provision: $*" >&2; exit 4; } +die_conflict(){ echo "gstack-gbrain-supabase-provision: $*" >&2; exit 5; } +die_net() { echo "gstack-gbrain-supabase-provision: $*" >&2; exit 8; } + +require_jq() { + command -v jq >/dev/null 2>&1 || die "jq is required. Install with: brew install jq" +} +require_curl() { + command -v curl >/dev/null 2>&1 || die "curl is required" +} + +require_pat() { + if [ -z "${SUPABASE_ACCESS_TOKEN:-}" ]; then + die_auth "SUPABASE_ACCESS_TOKEN is not set. Generate a PAT at https://supabase.com/dashboard/account/tokens" + fi +} + +require_db_pass() { + if [ -z "${DB_PASS:-}" ]; then + die "DB_PASS env var is required (never passed as argv — that leaks via ps/history)" + fi +} + +# api_call [] +# Handles: 401/403 → exit 3, 402 → 4, 409 → 5, 429 + 5xx → retry w/ +# exponential backoff up to 3 attempts. Returns the response body on +# stdout and HTTP status on an internal variable via a pipe trick. +# +# Because bash lacks multi-value returns, we write response body to a +# tmpfile + status to another tmpfile and the caller reads them. +api_call() { + local method="$1" + local apipath="$2" + local body_file="${3:-}" + + local url="$SUPABASE_API_BASE/$API_VERSION/$apipath" + local body_tmp + body_tmp=$(mktemp) + local status_tmp + status_tmp=$(mktemp) + # shellcheck disable=SC2064 + trap "rm -f '$body_tmp' '$status_tmp'" RETURN + + local attempt=0 + local max_attempts=3 + local backoff=2 + while : ; do + attempt=$((attempt + 1)) + local curl_args=( + --silent + --show-error + --max-time "$CURL_TIMEOUT" + -o "$body_tmp" + -w "%{http_code}" + -X "$method" + -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" + -H "Accept: application/json" + -H "Content-Type: application/json" + -H "User-Agent: gstack-gbrain-supabase-provision" + ) + if [ -n "$body_file" ]; then + curl_args+=(--data-binary "@$body_file") + fi + local status + if ! status=$(curl "${curl_args[@]}" "$url" 2>/dev/null); then + # curl itself failed (network, timeout, etc.). Retry. + if [ "$attempt" -ge "$max_attempts" ]; then + die_net "network failure calling $method $apipath after $attempt attempts" + fi + sleep "$backoff" + backoff=$((backoff * 2)) + continue + fi + + case "$status" in + 2??) + cat "$body_tmp" + printf '%s' "$status" > "$status_tmp" + return 0 + ;; + 401) + die_auth "401 Unauthorized — your PAT is invalid or expired. Re-generate at https://supabase.com/dashboard/account/tokens" + ;; + 403) + die_auth "403 Forbidden — your PAT lacks permission for $method $apipath. Regenerate with All Access scope." + ;; + 402) + die_quota "402 Payment Required — Supabase project/organization quota exceeded. See https://supabase.com/dashboard" + ;; + 409) + die_conflict "409 Conflict on $method $apipath — likely a duplicate project name. Pick a different name and re-run." + ;; + 429|5??) + if [ "$attempt" -ge "$max_attempts" ]; then + die_net "$status after $attempt attempts on $method $apipath" + fi + sleep "$backoff" + backoff=$((backoff * 2)) + continue + ;; + *) + # 400, 404, etc. — surface the error body for debugging. + local err + err=$(jq -r '.message // .error // empty' "$body_tmp" 2>/dev/null || true) + if [ -n "$err" ]; then + die "HTTP $status from $method $apipath: $err" + else + die "HTTP $status from $method $apipath (no error message in response)" + fi + ;; + esac + done +} + +cmd_list_orgs() { + local json_mode=false + while [ $# -gt 0 ]; do + case "$1" in + --json) json_mode=true; shift ;; + *) die "list-orgs: unknown flag: $1" ;; + esac + done + + require_jq; require_curl; require_pat + local resp + resp=$(api_call GET organizations) + if $json_mode; then + printf '%s' "$resp" | jq '{orgs: map({slug: .slug, name: .name})}' + else + printf '%s' "$resp" | jq -r '.[] | "\(.slug)\t\(.name)"' + fi +} + +cmd_create() { + local name="" region="" org_slug="" + local json_mode=false + local instance_size="" + while [ $# -gt 0 ]; do + case "$1" in + --json) json_mode=true; shift ;; + --instance-size) instance_size="$2"; shift 2 ;; + --*) die "create: unknown flag: $1" ;; + *) + if [ -z "$name" ]; then name="$1" + elif [ -z "$region" ]; then region="$1" + elif [ -z "$org_slug" ]; then org_slug="$1" + else die "create: too many positional arguments" + fi + shift + ;; + esac + done + [ -z "$name" ] && die "create: missing " + [ -z "$region" ] && die "create: missing " + [ -z "$org_slug" ] && die "create: missing " + + require_jq; require_curl; require_pat; require_db_pass + + local body_file + body_file=$(mktemp) + # shellcheck disable=SC2064 + trap "rm -f '$body_file'" RETURN + if [ -n "$instance_size" ]; then + jq -n \ + --arg name "$name" \ + --arg db_pass "$DB_PASS" \ + --arg organization_slug "$org_slug" \ + --arg region "$region" \ + --arg desired_instance_size "$instance_size" \ + '{name: $name, db_pass: $db_pass, organization_slug: $organization_slug, region: $region, desired_instance_size: $desired_instance_size}' \ + > "$body_file" + else + jq -n \ + --arg name "$name" \ + --arg db_pass "$DB_PASS" \ + --arg organization_slug "$org_slug" \ + --arg region "$region" \ + '{name: $name, db_pass: $db_pass, organization_slug: $organization_slug, region: $region}' \ + > "$body_file" + fi + + local resp + resp=$(api_call POST projects "$body_file") + if $json_mode; then + printf '%s' "$resp" | jq '{ref, name, region, organization_slug, status}' + else + printf '%s' "$resp" | jq -r '"ref=\(.ref) status=\(.status) region=\(.region)"' + fi +} + +cmd_wait() { + local ref="" timeout="$DEFAULT_WAIT_TIMEOUT" + local json_mode=false + while [ $# -gt 0 ]; do + case "$1" in + --timeout) timeout="$2"; shift 2 ;; + --json) json_mode=true; shift ;; + --*) die "wait: unknown flag: $1" ;; + *) ref="$1"; shift ;; + esac + done + [ -z "$ref" ] && die "wait: missing " + + require_jq; require_curl; require_pat + + local elapsed=0 + while : ; do + local resp + resp=$(api_call GET "projects/$ref") + local status + status=$(printf '%s' "$resp" | jq -r '.status // "UNKNOWN"') + case "$status" in + ACTIVE_HEALTHY) + if $json_mode; then + jq -n --arg ref "$ref" --arg status "$status" --argjson elapsed "$elapsed" \ + '{ref: $ref, status: $status, elapsed_s: $elapsed}' + else + echo "ready ref=$ref status=$status elapsed_s=$elapsed" + fi + return 0 + ;; + INIT_FAILED|REMOVED|RESTORE_FAILED|PAUSE_FAILED) + echo "gstack-gbrain-supabase-provision: project $ref reached terminal failure state '$status'" >&2 + exit 7 + ;; + COMING_UP|INACTIVE|ACTIVE_UNHEALTHY|UNKNOWN|RESTORING|UPGRADING|PAUSING|RESTARTING|RESIZING|GOING_DOWN) + # Still provisioning — keep polling. + ;; + *) + # Unexpected status from Supabase. Log but keep polling. + echo "gstack-gbrain-supabase-provision: unexpected status '$status' — continuing to poll" >&2 + ;; + esac + + if [ "$elapsed" -ge "$timeout" ]; then + echo "gstack-gbrain-supabase-provision: wait timed out after ${timeout}s (last status: $status)" >&2 + echo "gstack-gbrain-supabase-provision: re-run with /setup-gbrain --resume-provision $ref" >&2 + exit 6 + fi + sleep "$POLL_INTERVAL" + elapsed=$((elapsed + POLL_INTERVAL)) + done +} + +cmd_pooler_url() { + local ref="" + local json_mode=false + while [ $# -gt 0 ]; do + case "$1" in + --json) json_mode=true; shift ;; + --*) die "pooler-url: unknown flag: $1" ;; + *) ref="$1"; shift ;; + esac + done + [ -z "$ref" ] && die "pooler-url: missing " + + require_jq; require_curl; require_pat; require_db_pass + + local resp + resp=$(api_call GET "projects/$ref/config/database/pooler") + + # Prefer the singular Session Pooler config when Supabase returns an + # array (response shape can vary by project state). Fall back to the + # first PRIMARY entry if no "session" pool_mode is present. + local db_user db_host db_port db_name + local first_or_session + if printf '%s' "$resp" | jq -e 'type == "array"' >/dev/null 2>&1; then + first_or_session=$(printf '%s' "$resp" | jq '[.[] | select(.pool_mode == "session")][0] // .[0]') + else + first_or_session="$resp" + fi + + db_user=$(printf '%s' "$first_or_session" | jq -r '.db_user // empty') + db_host=$(printf '%s' "$first_or_session" | jq -r '.db_host // empty') + db_port=$(printf '%s' "$first_or_session" | jq -r '.db_port // empty') + db_name=$(printf '%s' "$first_or_session" | jq -r '.db_name // empty') + + if [ -z "$db_user" ] || [ -z "$db_host" ] || [ -z "$db_port" ] || [ -z "$db_name" ]; then + die "pooler-url: missing pooler config fields (db_user/db_host/db_port/db_name); re-poll or check project state" + fi + + local url="postgresql://${db_user}:${DB_PASS}@${db_host}:${db_port}/${db_name}" + + if $json_mode; then + jq -n --arg ref "$ref" --arg pooler_url "$url" '{ref: $ref, pooler_url: $pooler_url}' + else + # Non-JSON mode prints the URL; callers capturing it into a variable + # keep it in process memory only. + echo "$url" + fi +} + +cmd_list_orphans() { + local name_prefix="gbrain" + local json_mode=false + while [ $# -gt 0 ]; do + case "$1" in + --name-prefix) name_prefix="$2"; shift 2 ;; + --json) json_mode=true; shift ;; + --*) die "list-orphans: unknown flag: $1" ;; + *) die "list-orphans: unexpected arg: $1" ;; + esac + done + + require_jq; require_curl; require_pat + local all + all=$(api_call GET projects) + + # Extract the active brain's ref from ~/.gbrain/config.json if present. + # Pooler URL format: postgresql://postgres.:@... + local active_ref="null" + local gbrain_cfg="$HOME/.gbrain/config.json" + if [ -f "$gbrain_cfg" ]; then + local url + url=$(jq -r '.database_url // empty' "$gbrain_cfg" 2>/dev/null || true) + if [ -n "$url" ]; then + # Extract user portion before the colon: postgresql://USER:pw@... + local user + user=$(printf '%s' "$url" | sed -E 's|^[a-z]+://([^:]+):.*$|\1|') + # User format: postgres. — pull ref suffix + case "$user" in + postgres.*) + local ref="${user#postgres.}" + active_ref=$(jq -Rn --arg r "$ref" '$r') + ;; + esac + fi + fi + + local orphans + orphans=$(printf '%s' "$all" | jq \ + --arg prefix "$name_prefix" \ + --argjson active "$active_ref" \ + '[.[] + | select(.name | startswith($prefix)) + | select(.ref != $active) + | {ref: .ref, name: .name, created_at: .created_at, region: .region}]') + + jq -n --argjson active "$active_ref" --argjson orphans "$orphans" \ + '{active_ref: $active, orphans: $orphans}' +} + +cmd_delete_project() { + local ref="" + local json_mode=false + while [ $# -gt 0 ]; do + case "$1" in + --json) json_mode=true; shift ;; + --*) die "delete-project: unknown flag: $1" ;; + *) ref="$1"; shift ;; + esac + done + [ -z "$ref" ] && die "delete-project: missing " + + require_jq; require_curl; require_pat + api_call DELETE "projects/$ref" >/dev/null + jq -n --arg ref "$ref" '{deleted_ref: $ref}' +} + +case "${1:-}" in + list-orgs) shift; cmd_list_orgs "$@" ;; + create) shift; cmd_create "$@" ;; + wait) shift; cmd_wait "$@" ;; + pooler-url) shift; cmd_pooler_url "$@" ;; + list-orphans) shift; cmd_list_orphans "$@" ;; + delete-project) shift; cmd_delete_project "$@" ;; + --help|-h|help) sed -n '2,80p' "$0" | sed 's/^# \{0,1\}//' ;; + "") die "usage: gstack-gbrain-supabase-provision {list-orgs|create|wait|pooler-url|list-orphans|delete-project|--help}" ;; + *) die "unknown subcommand: $1" ;; +esac diff --git a/bin/gstack-gbrain-supabase-verify b/bin/gstack-gbrain-supabase-verify new file mode 100755 index 00000000..5a3b04c5 --- /dev/null +++ b/bin/gstack-gbrain-supabase-verify @@ -0,0 +1,126 @@ +#!/usr/bin/env bash +# gstack-gbrain-supabase-verify — structural check on a Supabase Session +# Pooler URL before handing it to `gbrain init`. +# +# Usage: +# gstack-gbrain-supabase-verify +# echo "" | gstack-gbrain-supabase-verify - +# +# Accepts ONLY Session Pooler URLs (port 6543, host *.pooler.supabase.com). +# Rejects direct-connection URLs (db.*.supabase.co:5432) since those are +# IPv6-only and fail in many environments — gbrain's init wizard warns +# about this at init.ts:150-158. +# +# Canonical shape (per gbrain init.ts:266): +# postgresql://postgres.:@aws-0-.pooler.supabase.com:6543/postgres +# +# Exit codes: +# 0 — URL passes structural check +# 2 — invalid format (bad scheme, port, host, userinfo, or empty password) +# 3 — direct-connection URL rejected (common mistake, special-cased for UX) +# +# The verifier never makes a network call; purely a regex match. Whether +# the URL actually works (database up, password correct, host reachable) +# is gbrain's problem at init time. +# +# Reads URL from: +# 1. argv[1] if provided and not "-" +# 2. stdin if argv[1] is "-" or missing +# +# Never echoes the URL to stderr (it contains a password). Error messages +# refer to "the URL" generically. +set -euo pipefail + +die() { echo "gstack-gbrain-supabase-verify: $*" >&2; exit 2; } +reject_direct() { + cat >&2 <:@aws-0-.pooler.supabase.com:6543/postgres +EOF + exit 3 +} + +URL="" +case "${1:-}" in + -) URL=$(cat) ;; + "") URL=$(cat) ;; + *) URL="$1" ;; +esac + +URL=$(printf '%s' "$URL" | tr -d '[:space:]') +[ -z "$URL" ] && die "empty URL" + +# Scheme: must be postgresql:// or postgres://. Explicitly reject other +# schemes rather than guess. +case "$URL" in + postgresql://*|postgres://*) ;; + *) die "bad scheme (must start with postgresql:// or postgres://)" ;; +esac + +# Strip scheme to expose userinfo + host + port + path. +rest="${URL#*://}" + +# Userinfo portion: everything before the first @. Must contain a : (user:pass). +case "$rest" in + *@*) ;; + *) die "missing userinfo (expected postgres.:@host)" ;; +esac +userinfo="${rest%%@*}" +after_at="${rest#*@}" + +# Userinfo must be user:password with neither part empty. +case "$userinfo" in + *:*) ;; + *) die "userinfo missing password separator (expected user:password@)" ;; +esac +user_part="${userinfo%%:*}" +pass_part="${userinfo#*:}" +[ -z "$user_part" ] && die "empty user portion in userinfo" +[ -z "$pass_part" ] && die "empty password in userinfo" + +# Host + port + path. +# Direct-connection detection FIRST (specific error beats generic). +case "$after_at" in + db.*.supabase.co:5432*|db.*.supabase.co/*|db.*.supabase.co) reject_direct ;; +esac + +# Extract host:port (before first / if present). +hostport="${after_at%%/*}" +case "$hostport" in + *:*) ;; + *) die "missing port (Session Pooler requires :6543)" ;; +esac +host="${hostport%:*}" +port="${hostport##*:}" + +# Host must be *.pooler.supabase.com (case-insensitive). +host_lower=$(printf '%s' "$host" | tr '[:upper:]' '[:lower:]') +case "$host_lower" in + *.pooler.supabase.com) ;; + *) die "host '$host' is not a Supabase Session Pooler (expected *.pooler.supabase.com)" ;; +esac + +# Port must be 6543 (Session Pooler default). +if [ "$port" != "6543" ]; then + die "port must be 6543 for Session Pooler (got $port)" +fi + +# User portion should look like postgres. (20-char lowercase ref, +# per the Supabase Management API contract). Not strictly required by +# gbrain, but rejecting a plain "postgres" user catches a common paste +# error where someone grabs the Direct URL userinfo by mistake. +case "$user_part" in + postgres.*) ;; + *) die "user portion '$user_part' should be 'postgres.' (20-char ref)" ;; +esac + +echo "ok" diff --git a/health/SKILL.md b/health/SKILL.md index ee1418f1..c7e334bc 100644 --- a/health/SKILL.md +++ b/health/SKILL.md @@ -1067,6 +1067,12 @@ command -v knip >/dev/null 2>&1 && echo "DEADCODE: knip" # Shell linting command -v shellcheck >/dev/null 2>&1 && ls *.sh scripts/*.sh bin/*.sh 2>/dev/null | head -1 | xargs -I{} echo "SHELL: shellcheck" + +# GBrain presence (D6) — only report as a dimension if gbrain is actually +# set up; otherwise skip so machines without gbrain aren't penalized. +if command -v gbrain >/dev/null 2>&1 && [ -f "$HOME/.gbrain/config.json" ]; then + echo "GBRAIN: gbrain doctor --json (wrapped in timeout 5s)" +fi ``` Use Glob to search for shell scripts: @@ -1131,11 +1137,12 @@ Score each category on a 0-10 scale using this rubric: | Category | Weight | 10 | 7 | 4 | 0 | |-----------|--------|------|-----------|------------|-----------| -| Type check | 25% | Clean (exit 0) | <10 errors | <50 errors | >=50 errors | -| Lint | 20% | Clean (exit 0) | <5 warnings | <20 warnings | >=20 warnings | -| Tests | 30% | All pass (exit 0) | >95% pass | >80% pass | <=80% pass | -| Dead code | 15% | Clean (exit 0) | <5 unused exports | <20 unused | >=20 unused | -| Shell lint | 10% | Clean (exit 0) | <5 issues | >=5 issues | N/A (skip) | +| Type check | 22% | Clean (exit 0) | <10 errors | <50 errors | >=50 errors | +| Lint | 18% | Clean (exit 0) | <5 warnings | <20 warnings | >=20 warnings | +| Tests | 28% | All pass (exit 0) | >95% pass | >80% pass | <=80% pass | +| Dead code | 13% | Clean (exit 0) | <5 unused exports | <20 unused | >=20 unused | +| Shell lint | 9% | Clean (exit 0) | <5 issues | >=5 issues | N/A (skip) | +| GBrain (D6) | 10% | doctor=ok, queue<10, pushed <24h | doctor=warnings OR queue<100 OR pushed <72h | doctor broken OR queue>=100 OR pushed >=72h | N/A (gbrain not installed) | **Parsing tool output for counts:** - **tsc:** Count lines matching `error TS` in output. @@ -1146,11 +1153,30 @@ Score each category on a 0-10 scale using this rubric: **Composite score:** ``` -composite = (typecheck_score * 0.25) + (lint_score * 0.20) + (test_score * 0.30) + (deadcode_score * 0.15) + (shell_score * 0.10) +composite = (typecheck_score * 0.22) + (lint_score * 0.18) + (test_score * 0.28) + (deadcode_score * 0.13) + (shell_score * 0.09) + (gbrain_score * 0.10) ``` -If a category is skipped (tool not available), redistribute its weight proportionally -among the remaining categories. +If a category is skipped (tool not available — includes GBrain when gbrain +is not installed), redistribute its weight proportionally among the +remaining categories. + +**GBrain sub-score computation (D6):** + +``` +doctor_component: 10 if `gbrain doctor --json | jq -r .status` == "ok"; + 7 if "warnings"; 0 otherwise (or command times out after 5s). +queue_component: 10 if ~/.gstack/.brain-queue.jsonl has <10 lines; + 7 if 10-100; 0 if >=100 (suggests secret-scan rejections + piling up). N/A if gbrain_sync_mode == off. +push_component: 10 if (now - mtime of ~/.gstack/.brain-last-push) < 24h; + 7 if <72h; 0 if >=72h. N/A if gbrain_sync_mode == off. +gbrain_score = 0.5 * doctor_component + 0.3 * queue_component + 0.2 * push_component + (redistribute 0.3 + 0.2 into doctor when sync_mode is off: + gbrain_score = doctor_component in that case) +``` + +The `gbrain doctor --json` call MUST be wrapped in `timeout 5s` so a hung +or misconfigured gbrain doesn't stall the entire /health dashboard. --- @@ -1173,6 +1199,7 @@ Lint biome check . 8/10 WARNING 2s 3 warnings Tests bun test 10/10 CLEAN 12s 47/47 passed Dead code knip 7/10 WARNING 5s 4 unused exports Shell lint shellcheck 10/10 CLEAN 1s 0 issues +GBrain gbrain doctor 10/10 CLEAN <1s doctor=ok, queue=3, pushed 2h ago COMPOSITE SCORE: 9.1 / 10 @@ -1206,17 +1233,19 @@ eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" && mkdir -p ~/.gst Append one JSONL line to `~/.gstack/projects/$SLUG/health-history.jsonl`: ```json -{"ts":"2026-03-31T14:30:00Z","branch":"main","score":9.1,"typecheck":10,"lint":8,"test":10,"deadcode":7,"shell":10,"duration_s":23} +{"ts":"2026-03-31T14:30:00Z","branch":"main","score":9.1,"typecheck":10,"lint":8,"test":10,"deadcode":7,"shell":10,"gbrain":10,"duration_s":23} ``` Fields: - `ts` -- ISO 8601 timestamp - `branch` -- current git branch - `score` -- composite score (one decimal) -- `typecheck`, `lint`, `test`, `deadcode`, `shell` -- individual category scores (integer 0-10) +- `typecheck`, `lint`, `test`, `deadcode`, `shell`, `gbrain` -- individual category scores (integer 0-10) - `duration_s` -- total time for all tools in seconds -If a category was skipped, set its value to `null`. +If a category was skipped, set its value to `null`. Pre-D6 history entries +won't have a `gbrain` field — treat them as `null` for trend comparison +and start new tracking from the first post-D6 run. --- @@ -1235,12 +1264,12 @@ tail -10 ~/.gstack/projects/$SLUG/health-history.jsonl 2>/dev/null || echo "NO_H ``` HEALTH TREND (last 5 runs) ========================== -Date Branch Score TC Lint Test Dead Shell ----------- ----------- ----- -- ---- ---- ---- ----- -2026-03-28 main 9.4 10 9 10 8 10 -2026-03-29 feat/auth 8.8 10 7 10 7 10 -2026-03-30 feat/auth 8.2 10 6 9 7 10 -2026-03-31 feat/auth 9.1 10 8 10 7 10 +Date Branch Score TC Lint Test Dead Shell GBrain +---------- ----------- ----- -- ---- ---- ---- ----- ------ +2026-03-28 main 9.4 10 9 10 8 10 10 +2026-03-29 feat/auth 8.8 10 7 10 7 10 10 +2026-03-30 feat/auth 8.2 10 6 9 7 10 7 +2026-03-31 feat/auth 9.1 10 8 10 7 10 10 Trend: IMPROVING (+0.9 since last run) ``` diff --git a/health/SKILL.md.tmpl b/health/SKILL.md.tmpl index c116ce75..ca70c665 100644 --- a/health/SKILL.md.tmpl +++ b/health/SKILL.md.tmpl @@ -69,6 +69,12 @@ command -v knip >/dev/null 2>&1 && echo "DEADCODE: knip" # Shell linting command -v shellcheck >/dev/null 2>&1 && ls *.sh scripts/*.sh bin/*.sh 2>/dev/null | head -1 | xargs -I{} echo "SHELL: shellcheck" + +# GBrain presence (D6) — only report as a dimension if gbrain is actually +# set up; otherwise skip so machines without gbrain aren't penalized. +if command -v gbrain >/dev/null 2>&1 && [ -f "$HOME/.gbrain/config.json" ]; then + echo "GBRAIN: gbrain doctor --json (wrapped in timeout 5s)" +fi ``` Use Glob to search for shell scripts: @@ -133,11 +139,12 @@ Score each category on a 0-10 scale using this rubric: | Category | Weight | 10 | 7 | 4 | 0 | |-----------|--------|------|-----------|------------|-----------| -| Type check | 25% | Clean (exit 0) | <10 errors | <50 errors | >=50 errors | -| Lint | 20% | Clean (exit 0) | <5 warnings | <20 warnings | >=20 warnings | -| Tests | 30% | All pass (exit 0) | >95% pass | >80% pass | <=80% pass | -| Dead code | 15% | Clean (exit 0) | <5 unused exports | <20 unused | >=20 unused | -| Shell lint | 10% | Clean (exit 0) | <5 issues | >=5 issues | N/A (skip) | +| Type check | 22% | Clean (exit 0) | <10 errors | <50 errors | >=50 errors | +| Lint | 18% | Clean (exit 0) | <5 warnings | <20 warnings | >=20 warnings | +| Tests | 28% | All pass (exit 0) | >95% pass | >80% pass | <=80% pass | +| Dead code | 13% | Clean (exit 0) | <5 unused exports | <20 unused | >=20 unused | +| Shell lint | 9% | Clean (exit 0) | <5 issues | >=5 issues | N/A (skip) | +| GBrain (D6) | 10% | doctor=ok, queue<10, pushed <24h | doctor=warnings OR queue<100 OR pushed <72h | doctor broken OR queue>=100 OR pushed >=72h | N/A (gbrain not installed) | **Parsing tool output for counts:** - **tsc:** Count lines matching `error TS` in output. @@ -148,11 +155,30 @@ Score each category on a 0-10 scale using this rubric: **Composite score:** ``` -composite = (typecheck_score * 0.25) + (lint_score * 0.20) + (test_score * 0.30) + (deadcode_score * 0.15) + (shell_score * 0.10) +composite = (typecheck_score * 0.22) + (lint_score * 0.18) + (test_score * 0.28) + (deadcode_score * 0.13) + (shell_score * 0.09) + (gbrain_score * 0.10) ``` -If a category is skipped (tool not available), redistribute its weight proportionally -among the remaining categories. +If a category is skipped (tool not available — includes GBrain when gbrain +is not installed), redistribute its weight proportionally among the +remaining categories. + +**GBrain sub-score computation (D6):** + +``` +doctor_component: 10 if `gbrain doctor --json | jq -r .status` == "ok"; + 7 if "warnings"; 0 otherwise (or command times out after 5s). +queue_component: 10 if ~/.gstack/.brain-queue.jsonl has <10 lines; + 7 if 10-100; 0 if >=100 (suggests secret-scan rejections + piling up). N/A if gbrain_sync_mode == off. +push_component: 10 if (now - mtime of ~/.gstack/.brain-last-push) < 24h; + 7 if <72h; 0 if >=72h. N/A if gbrain_sync_mode == off. +gbrain_score = 0.5 * doctor_component + 0.3 * queue_component + 0.2 * push_component + (redistribute 0.3 + 0.2 into doctor when sync_mode is off: + gbrain_score = doctor_component in that case) +``` + +The `gbrain doctor --json` call MUST be wrapped in `timeout 5s` so a hung +or misconfigured gbrain doesn't stall the entire /health dashboard. --- @@ -175,6 +201,7 @@ Lint biome check . 8/10 WARNING 2s 3 warnings Tests bun test 10/10 CLEAN 12s 47/47 passed Dead code knip 7/10 WARNING 5s 4 unused exports Shell lint shellcheck 10/10 CLEAN 1s 0 issues +GBrain gbrain doctor 10/10 CLEAN <1s doctor=ok, queue=3, pushed 2h ago COMPOSITE SCORE: 9.1 / 10 @@ -208,17 +235,19 @@ DETAILS: Lint (3 warnings) Append one JSONL line to `~/.gstack/projects/$SLUG/health-history.jsonl`: ```json -{"ts":"2026-03-31T14:30:00Z","branch":"main","score":9.1,"typecheck":10,"lint":8,"test":10,"deadcode":7,"shell":10,"duration_s":23} +{"ts":"2026-03-31T14:30:00Z","branch":"main","score":9.1,"typecheck":10,"lint":8,"test":10,"deadcode":7,"shell":10,"gbrain":10,"duration_s":23} ``` Fields: - `ts` -- ISO 8601 timestamp - `branch` -- current git branch - `score` -- composite score (one decimal) -- `typecheck`, `lint`, `test`, `deadcode`, `shell` -- individual category scores (integer 0-10) +- `typecheck`, `lint`, `test`, `deadcode`, `shell`, `gbrain` -- individual category scores (integer 0-10) - `duration_s` -- total time for all tools in seconds -If a category was skipped, set its value to `null`. +If a category was skipped, set its value to `null`. Pre-D6 history entries +won't have a `gbrain` field — treat them as `null` for trend comparison +and start new tracking from the first post-D6 run. --- @@ -237,12 +266,12 @@ tail -10 ~/.gstack/projects/$SLUG/health-history.jsonl 2>/dev/null || echo "NO_H ``` HEALTH TREND (last 5 runs) ========================== -Date Branch Score TC Lint Test Dead Shell ----------- ----------- ----- -- ---- ---- ---- ----- -2026-03-28 main 9.4 10 9 10 8 10 -2026-03-29 feat/auth 8.8 10 7 10 7 10 -2026-03-30 feat/auth 8.2 10 6 9 7 10 -2026-03-31 feat/auth 9.1 10 8 10 7 10 +Date Branch Score TC Lint Test Dead Shell GBrain +---------- ----------- ----- -- ---- ---- ---- ----- ------ +2026-03-28 main 9.4 10 9 10 8 10 10 +2026-03-29 feat/auth 8.8 10 7 10 7 10 10 +2026-03-30 feat/auth 8.2 10 6 9 7 10 7 +2026-03-31 feat/auth 9.1 10 8 10 7 10 10 Trend: IMPROVING (+0.9 since last run) ``` diff --git a/package.json b/package.json index e56c89f8..f5630f17 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.11.1.0", + "version": "1.12.0.0", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/setup-gbrain/SKILL.md b/setup-gbrain/SKILL.md new file mode 100644 index 00000000..cc1c752e --- /dev/null +++ b/setup-gbrain/SKILL.md @@ -0,0 +1,1447 @@ +--- +name: setup-gbrain +preamble-tier: 2 +version: 1.0.0 +description: | + Set up gbrain for this coding agent: install the CLI, initialize a + local PGLite or Supabase brain, register MCP, capture per-remote trust + policy. One command from zero to "gbrain is running, and this agent + can call it." Use when: "setup gbrain", "connect gbrain", "start + gbrain", "install gbrain", "configure gbrain for this machine". (gstack) +triggers: + - setup gbrain + - install gbrain + - connect gbrain + - start gbrain + - configure gbrain +allowed-tools: + - Bash + - Read + - Write + - Edit + - Glob + - Grep + - AskUserQuestion +--- + + + +## Preamble (run first) + +```bash +_UPD=$(~/.claude/skills/gstack/bin/gstack-update-check 2>/dev/null || .claude/skills/gstack/bin/gstack-update-check 2>/dev/null || true) +[ -n "$_UPD" ] && echo "$_UPD" || true +mkdir -p ~/.gstack/sessions +touch ~/.gstack/sessions/"$PPID" +_SESSIONS=$(find ~/.gstack/sessions -mmin -120 -type f 2>/dev/null | wc -l | tr -d ' ') +find ~/.gstack/sessions -mmin +120 -type f -exec rm {} + 2>/dev/null || true +_PROACTIVE=$(~/.claude/skills/gstack/bin/gstack-config get proactive 2>/dev/null || echo "true") +_PROACTIVE_PROMPTED=$([ -f ~/.gstack/.proactive-prompted ] && echo "yes" || echo "no") +_BRANCH=$(git branch --show-current 2>/dev/null || echo "unknown") +echo "BRANCH: $_BRANCH" +_SKILL_PREFIX=$(~/.claude/skills/gstack/bin/gstack-config get skill_prefix 2>/dev/null || echo "false") +echo "PROACTIVE: $_PROACTIVE" +echo "PROACTIVE_PROMPTED: $_PROACTIVE_PROMPTED" +echo "SKILL_PREFIX: $_SKILL_PREFIX" +source <(~/.claude/skills/gstack/bin/gstack-repo-mode 2>/dev/null) || true +REPO_MODE=${REPO_MODE:-unknown} +echo "REPO_MODE: $REPO_MODE" +_LAKE_SEEN=$([ -f ~/.gstack/.completeness-intro-seen ] && echo "yes" || echo "no") +echo "LAKE_INTRO: $_LAKE_SEEN" +_TEL=$(~/.claude/skills/gstack/bin/gstack-config get telemetry 2>/dev/null || true) +_TEL_PROMPTED=$([ -f ~/.gstack/.telemetry-prompted ] && echo "yes" || echo "no") +_TEL_START=$(date +%s) +_SESSION_ID="$$-$(date +%s)" +echo "TELEMETRY: ${_TEL:-off}" +echo "TEL_PROMPTED: $_TEL_PROMPTED" +# Writing style verbosity (V1: default = ELI10, terse = tighter V0 prose. +# Read on every skill run so terse mode takes effect without a restart.) +_EXPLAIN_LEVEL=$(~/.claude/skills/gstack/bin/gstack-config get explain_level 2>/dev/null || echo "default") +if [ "$_EXPLAIN_LEVEL" != "default" ] && [ "$_EXPLAIN_LEVEL" != "terse" ]; then _EXPLAIN_LEVEL="default"; fi +echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL" +# Question tuning (see /plan-tune). Observational only in V1. +_QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "false") +echo "QUESTION_TUNING: $_QUESTION_TUNING" +mkdir -p ~/.gstack/analytics +if [ "$_TEL" != "off" ]; then +echo '{"skill":"setup-gbrain","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true +fi +# zsh-compatible: use find instead of glob to avoid NOMATCH error +for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do + if [ -f "$_PF" ]; then + if [ "$_TEL" != "off" ] && [ -x "~/.claude/skills/gstack/bin/gstack-telemetry-log" ]; then + ~/.claude/skills/gstack/bin/gstack-telemetry-log --event-type skill_run --skill _pending_finalize --outcome unknown --session-id "$_SESSION_ID" 2>/dev/null || true + fi + rm -f "$_PF" 2>/dev/null || true + fi + break +done +# Learnings count +eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" 2>/dev/null || true +_LEARN_FILE="${GSTACK_HOME:-$HOME/.gstack}/projects/${SLUG:-unknown}/learnings.jsonl" +if [ -f "$_LEARN_FILE" ]; then + _LEARN_COUNT=$(wc -l < "$_LEARN_FILE" 2>/dev/null | tr -d ' ') + echo "LEARNINGS: $_LEARN_COUNT entries loaded" + if [ "$_LEARN_COUNT" -gt 5 ] 2>/dev/null; then + ~/.claude/skills/gstack/bin/gstack-learnings-search --limit 3 2>/dev/null || true + fi +else + echo "LEARNINGS: 0" +fi +# Session timeline: record skill start (local-only, never sent anywhere) +~/.claude/skills/gstack/bin/gstack-timeline-log '{"skill":"setup-gbrain","event":"started","branch":"'"$_BRANCH"'","session":"'"$_SESSION_ID"'"}' 2>/dev/null & +# Check if CLAUDE.md has routing rules +_HAS_ROUTING="no" +if [ -f CLAUDE.md ] && grep -q "## Skill routing" CLAUDE.md 2>/dev/null; then + _HAS_ROUTING="yes" +fi +_ROUTING_DECLINED=$(~/.claude/skills/gstack/bin/gstack-config get routing_declined 2>/dev/null || echo "false") +echo "HAS_ROUTING: $_HAS_ROUTING" +echo "ROUTING_DECLINED: $_ROUTING_DECLINED" +# Vendoring deprecation: detect if CWD has a vendored gstack copy +_VENDORED="no" +if [ -d ".claude/skills/gstack" ] && [ ! -L ".claude/skills/gstack" ]; then + if [ -f ".claude/skills/gstack/VERSION" ] || [ -d ".claude/skills/gstack/.git" ]; then + _VENDORED="yes" + fi +fi +echo "VENDORED_GSTACK: $_VENDORED" +echo "MODEL_OVERLAY: claude" +# Checkpoint mode (explicit = no auto-commit, continuous = WIP commits as you go) +_CHECKPOINT_MODE=$(~/.claude/skills/gstack/bin/gstack-config get checkpoint_mode 2>/dev/null || echo "explicit") +_CHECKPOINT_PUSH=$(~/.claude/skills/gstack/bin/gstack-config get checkpoint_push 2>/dev/null || echo "false") +echo "CHECKPOINT_MODE: $_CHECKPOINT_MODE" +echo "CHECKPOINT_PUSH: $_CHECKPOINT_PUSH" +# Detect spawned session (OpenClaw or other orchestrator) +[ -n "$OPENCLAW_SESSION" ] && echo "SPAWNED_SESSION: true" || true +``` + +If `PROACTIVE` is `"false"`, do not proactively suggest gstack skills AND do not +auto-invoke skills based on conversation context. Only run skills the user explicitly +types (e.g., /qa, /ship). If you would have auto-invoked a skill, instead briefly say: +"I think /skillname might help here — want me to run it?" and wait for confirmation. +The user opted out of proactive behavior. + +If `SKILL_PREFIX` is `"true"`, the user has namespaced skill names. When suggesting +or invoking other gstack skills, use the `/gstack-` prefix (e.g., `/gstack-qa` instead +of `/qa`, `/gstack-ship` instead of `/ship`). Disk paths are unaffected — always use +`~/.claude/skills/gstack/[skill-name]/SKILL.md` for reading skill files. + +If output shows `UPGRADE_AVAILABLE `: read `~/.claude/skills/gstack/gstack-upgrade/SKILL.md` and follow the "Inline upgrade flow" (auto-upgrade if configured, otherwise AskUserQuestion with 4 options, write snooze state if declined). + +If output shows `JUST_UPGRADED ` AND `SPAWNED_SESSION` is NOT set: tell +the user "Running gstack v{to} (just updated!)" and then check for new features to +surface. For each per-feature marker below, if the marker file is missing AND the +feature is plausibly useful for this user, use AskUserQuestion to let them try it. +Fire once per feature per user, NOT once per upgrade. + +**In spawned sessions (`SPAWNED_SESSION` = "true"): SKIP feature discovery entirely.** +Just print "Running gstack v{to}" and continue. Orchestrators do not want interactive +prompts from sub-sessions. + +**Feature discovery markers and prompts** (one at a time, max one per session): + +1. `~/.claude/skills/gstack/.feature-prompted-continuous-checkpoint` → + Prompt: "Continuous checkpoint auto-commits your work as you go with `WIP:` prefix + so you never lose progress to a crash. Local-only by default — doesn't push + anywhere unless you turn that on. Want to try it?" + Options: A) Enable continuous mode, B) Show me first (print the section from + the preamble Continuous Checkpoint Mode), C) Skip. + If A: run `~/.claude/skills/gstack/bin/gstack-config set checkpoint_mode continuous`. + Always: `touch ~/.claude/skills/gstack/.feature-prompted-continuous-checkpoint` + +2. `~/.claude/skills/gstack/.feature-prompted-model-overlay` → + Inform only (no prompt): "Model overlays are active. `MODEL_OVERLAY: {model}` + shown in the preamble output tells you which behavioral patch is applied. + Override with `--model` when regenerating skills (e.g., `bun run gen:skill-docs + --model gpt-5.4`). Default is claude." + Always: `touch ~/.claude/skills/gstack/.feature-prompted-model-overlay` + +After handling JUST_UPGRADED (prompts done or skipped), continue with the skill +workflow. + +If `WRITING_STYLE_PENDING` is `yes`: You're on the first skill run after upgrading +to gstack v1. Ask the user once about the new default writing style. Use AskUserQuestion: + +> v1 prompts = simpler. Technical terms get a one-sentence gloss on first use, +> questions are framed in outcome terms, sentences are shorter. +> +> Keep the new default, or prefer the older tighter prose? + +Options: +- A) Keep the new default (recommended — good writing helps everyone) +- B) Restore V0 prose — set `explain_level: terse` + +If A: leave `explain_level` unset (defaults to `default`). +If B: run `~/.claude/skills/gstack/bin/gstack-config set explain_level terse`. + +Always run (regardless of choice): +```bash +rm -f ~/.gstack/.writing-style-prompt-pending +touch ~/.gstack/.writing-style-prompted +``` + +This only happens once. If `WRITING_STYLE_PENDING` is `no`, skip this entirely. + +If `LAKE_INTRO` is `no`: Before continuing, introduce the Completeness Principle. +Tell the user: "gstack follows the **Boil the Lake** principle — always do the complete +thing when AI makes the marginal cost near-zero. Read more: https://garryslist.org/posts/boil-the-ocean" +Then offer to open the essay in their default browser: + +```bash +open https://garryslist.org/posts/boil-the-ocean +touch ~/.gstack/.completeness-intro-seen +``` + +Only run `open` if the user says yes. Always run `touch` to mark as seen. This only happens once. + +If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: After the lake intro is handled, +ask the user about telemetry. Use AskUserQuestion: + +> Help gstack get better! Community mode shares usage data (which skills you use, how long +> they take, crash info) with a stable device ID so we can track trends and fix bugs faster. +> No code, file paths, or repo names are ever sent. +> Change anytime with `gstack-config set telemetry off`. + +Options: +- A) Help gstack get better! (recommended) +- B) No thanks + +If A: run `~/.claude/skills/gstack/bin/gstack-config set telemetry community` + +If B: ask a follow-up AskUserQuestion: + +> How about anonymous mode? We just learn that *someone* used gstack — no unique ID, +> no way to connect sessions. Just a counter that helps us know if anyone's out there. + +Options: +- A) Sure, anonymous is fine +- B) No thanks, fully off + +If B→A: run `~/.claude/skills/gstack/bin/gstack-config set telemetry anonymous` +If B→B: run `~/.claude/skills/gstack/bin/gstack-config set telemetry off` + +Always run: +```bash +touch ~/.gstack/.telemetry-prompted +``` + +This only happens once. If `TEL_PROMPTED` is `yes`, skip this entirely. + +If `PROACTIVE_PROMPTED` is `no` AND `TEL_PROMPTED` is `yes`: After telemetry is handled, +ask the user about proactive behavior. Use AskUserQuestion: + +> gstack can proactively figure out when you might need a skill while you work — +> like suggesting /qa when you say "does this work?" or /investigate when you hit +> a bug. We recommend keeping this on — it speeds up every part of your workflow. + +Options: +- A) Keep it on (recommended) +- B) Turn it off — I'll type /commands myself + +If A: run `~/.claude/skills/gstack/bin/gstack-config set proactive true` +If B: run `~/.claude/skills/gstack/bin/gstack-config set proactive false` + +Always run: +```bash +touch ~/.gstack/.proactive-prompted +``` + +This only happens once. If `PROACTIVE_PROMPTED` is `yes`, skip this entirely. + +If `HAS_ROUTING` is `no` AND `ROUTING_DECLINED` is `false` AND `PROACTIVE_PROMPTED` is `yes`: +Check if a CLAUDE.md file exists in the project root. If it does not exist, create it. + +Use AskUserQuestion: + +> gstack works best when your project's CLAUDE.md includes skill routing rules. +> This tells Claude to use specialized workflows (like /ship, /investigate, /qa) +> instead of answering directly. It's a one-time addition, about 15 lines. + +Options: +- A) Add routing rules to CLAUDE.md (recommended) +- B) No thanks, I'll invoke skills manually + +If A: Append this section to the end of CLAUDE.md: + +```markdown + +## Skill routing + +When the user's request matches an available skill, invoke it via the Skill tool. The +skill has multi-step workflows, checklists, and quality gates that produce better +results than an ad-hoc answer. When in doubt, invoke the skill. A false positive is +cheaper than a false negative. + +Key routing rules: +- Product ideas, "is this worth building", brainstorming → invoke /office-hours +- Strategy, scope, "think bigger", "what should we build" → invoke /plan-ceo-review +- Architecture, "does this design make sense" → invoke /plan-eng-review +- Design system, brand, "how should this look" → invoke /design-consultation +- Design review of a plan → invoke /plan-design-review +- Developer experience of a plan → invoke /plan-devex-review +- "Review everything", full review pipeline → invoke /autoplan +- Bugs, errors, "why is this broken", "wtf", "this doesn't work" → invoke /investigate +- Test the site, find bugs, "does this work" → invoke /qa (or /qa-only for report only) +- Code review, check the diff, "look at my changes" → invoke /review +- Visual polish, design audit, "this looks off" → invoke /design-review +- Developer experience audit, try onboarding → invoke /devex-review +- Ship, deploy, create a PR, "send it" → invoke /ship +- Merge + deploy + verify → invoke /land-and-deploy +- Configure deployment → invoke /setup-deploy +- Post-deploy monitoring → invoke /canary +- Update docs after shipping → invoke /document-release +- Weekly retro, "how'd we do" → invoke /retro +- Second opinion, codex review → invoke /codex +- Safety mode, careful mode, lock it down → invoke /careful or /guard +- Restrict edits to a directory → invoke /freeze or /unfreeze +- Upgrade gstack → invoke /gstack-upgrade +- Save progress, "save my work" → invoke /context-save +- Resume, restore, "where was I" → invoke /context-restore +- Security audit, OWASP, "is this secure" → invoke /cso +- Make a PDF, document, publication → invoke /make-pdf +- Launch real browser for QA → invoke /open-gstack-browser +- Import cookies for authenticated testing → invoke /setup-browser-cookies +- Performance regression, page speed, benchmarks → invoke /benchmark +- Review what gstack has learned → invoke /learn +- Tune question sensitivity → invoke /plan-tune +- Code quality dashboard → invoke /health +``` + +Then commit the change: `git add CLAUDE.md && git commit -m "chore: add gstack skill routing rules to CLAUDE.md"` + +If B: run `~/.claude/skills/gstack/bin/gstack-config set routing_declined true` +Say "No problem. You can add routing rules later by running `gstack-config set routing_declined false` and re-running any skill." + +This only happens once per project. If `HAS_ROUTING` is `yes` or `ROUTING_DECLINED` is `true`, skip this entirely. + +If `VENDORED_GSTACK` is `yes`: This project has a vendored copy of gstack at +`.claude/skills/gstack/`. Vendoring is deprecated. We will not keep vendored copies +up to date, so this project's gstack will fall behind. + +Use AskUserQuestion (one-time per project, check for `~/.gstack/.vendoring-warned-$SLUG` marker): + +> This project has gstack vendored in `.claude/skills/gstack/`. Vendoring is deprecated. +> We won't keep this copy up to date, so you'll fall behind on new features and fixes. +> +> Want to migrate to team mode? It takes about 30 seconds. + +Options: +- A) Yes, migrate to team mode now +- B) No, I'll handle it myself + +If A: +1. Run `git rm -r .claude/skills/gstack/` +2. Run `echo '.claude/skills/gstack/' >> .gitignore` +3. Run `~/.claude/skills/gstack/bin/gstack-team-init required` (or `optional`) +4. Run `git add .claude/ .gitignore CLAUDE.md && git commit -m "chore: migrate gstack from vendored to team mode"` +5. Tell the user: "Done. Each developer now runs: `cd ~/.claude/skills/gstack && ./setup --team`" + +If B: say "OK, you're on your own to keep the vendored copy up to date." + +Always run (regardless of choice): +```bash +eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" 2>/dev/null || true +touch ~/.gstack/.vendoring-warned-${SLUG:-unknown} +``` + +This only happens once per project. If the marker file exists, skip entirely. + +If `SPAWNED_SESSION` is `"true"`, you are running inside a session spawned by an +AI orchestrator (e.g., OpenClaw). In spawned sessions: +- Do NOT use AskUserQuestion for interactive prompts. Auto-choose the recommended option. +- Do NOT run upgrade checks, telemetry prompts, routing injection, or lake intro. +- Focus on completing the task and reporting results via prose output. +- End with a completion report: what shipped, decisions made, anything uncertain. + +## AskUserQuestion Format + +**ALWAYS follow this structure for every AskUserQuestion call. Every element is non-skippable. If you find yourself about to skip any of them, stop and back up.** + +### Required shape + +Every AskUserQuestion reads like a decision brief, not a bullet list: + +``` +D + +ELI10: + +Stakes if we pick wrong: + +Recommendation: because + +Completeness: A=X/10, B=Y/10 (or: Note: options differ in kind, not coverage — no completeness score) + +Pros / cons: + +A)