From 2014557e7f096817c5fdd4b965c4c10eb40d0eb7 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Fri, 24 Apr 2026 01:38:21 -0700 Subject: [PATCH] =?UTF-8?q?v1.12.0.0=20feat:=20/setup-gbrain=20=E2=80=94?= =?UTF-8?q?=20coding-agent=20onboarding=20for=20gbrain=20(#1183)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(setup-gbrain): add gstack-gbrain-repo-policy bin helper Per-remote trust-tier store for the forthcoming /setup-gbrain skill. Tiers are the D3 triad (read-write / read-only / deny), keyed by a normalized remote URL so ssh-shorthand and https variants collapse to the same entry. The file carries _schema_version: 2 (D2-eng); legacy `allow` values from pre-D3 experiments auto-migrate to `read-write` on first read, idempotent, with a one-shot log line. Pure bash + jq to match the existing gstack-brain-* family. Atomic writes via tmpfile + rename. Policy file mode 0600. Corrupt files quarantine to .corrupt- and start fresh. Co-Authored-By: Claude Opus 4.7 (1M context) * test(setup-gbrain): unit tests for gstack-gbrain-repo-policy 24 tests covering normalize (ssh/https/shorthand/uppercase collapse to one key), set/get round-trip, all three D3 tiers accepted, invalid tiers rejected, file mode 0600, _schema_version field written on fresh files, legacy allow migration (including idempotence and preservation of non-allow entries), corrupt-JSON quarantine + fresh-file recovery, list output sorting, and get-without-arg auto-detect against a git repo with no origin. All tests green against a per-test tmpdir GSTACK_HOME so nothing leaks into the real ~/.gstack. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(setup-gbrain): add gstack-gbrain-detect state reporter Pure-introspection JSON emitter for the /setup-gbrain skill's start-up branching. Reports: gbrain presence + version on PATH, ~/.gbrain/config.json existence + engine, `gbrain doctor --json` health (wrapped in timeout 5s to match the /health D6 pattern), gstack-brain-sync mode via gstack-config, and ~/.gstack/.git presence for the memory-sync feature. Never modifies state. Always emits valid JSON even when every check is false. Handles malformed ~/.gbrain/config.json without crashing — gbrain_engine is null in that case, not an error. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(setup-gbrain): add gstack-gbrain-install with D5 detect-first + D19 PATH-shadow guard Clones gbrain at a pinned commit (v0.18.2) and registers it via `bun link`. Before any clone: D5 detect-first — probes ~/git/gbrain, ~/gbrain, and the install target for a valid pre-existing clone (package.json with name "gbrain" and bin.gbrain set). If one is found, `bun link` runs there instead of cloning a second copy. Prevents the day-one duplicate-install footgun on the skill author's own machine. After install: D19 PATH-shadow guard — reads the install-dir's package.json version, compares to `gbrain --version` on PATH. On mismatch: exits 3, prints every gbrain binary on PATH via `type -a`, and gives a remediation menu. Setup skills refuse broken environments instead of warning and continuing. Prereq checks (bun, git, https://github.com reachability) fail fast with install hints. --dry-run and --validate-only flags let the skill probe the plan without touching state; tests use them to cover D5 and D19 without exercising real bun link. Pin is a load-bearing version: setup-gbrain v1 verified against gbrain v0.18.2. Updating requires re-running Pre-Impl Gate 1 to verify gbrain's CLI + config shapes haven't drifted. Co-Authored-By: Claude Opus 4.7 (1M context) * test(setup-gbrain): unit tests for gstack-gbrain-detect + install 15 tests covering: detect emits valid JSON when nothing configured, reports gstack_brain_git on GSTACK_HOME/.git presence, reads ~/.gbrain/config.json engine, tolerates malformed config, detects a mocked gbrain binary on PATH with version parsing. For install: D5 detect-first uses ~/git/gbrain fixtures under a sandboxed HOME, verifies fall-through to fresh clone when no valid clone exists, rejects invalid package.json shapes. D19 PATH-shadow validation uses a fake gbrain on a minimal SAFE_PATH to simulate version mismatch, same-version-pass, v-prefix tolerance, missing binary on PATH, and missing version field in package.json. --validate-only mode in the install bin makes the D19 check unit- testable without running real bun link (which touches ~/.bun/bin). Co-Authored-By: Claude Opus 4.7 (1M context) * feat(setup-gbrain): add gstack-gbrain-lib.sh with read_secret_to_env (D3-eng) Shared secret-read helper for PAT (D11) and pooler URL paste (D16). One implementation of the hardest-to-get-right pattern: stty -echo + SIGINT/TERM/EXIT trap that restores terminal mode, read into a named env var, optional redacted preview. Validates the target var name against [A-Z_][A-Z0-9_]* to prevent bash name-injection via `read -r "$varname"`. When stdin is not a TTY (CI, piped tests) the stty branches skip cleanly — piped input doesn't echo anyway. Exports the var after read so subprocesses inherit it; callers own the `unset` at handoff time. Sourced, not executed — no +x bit. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(setup-gbrain): add gstack-gbrain-supabase-verify structural URL check Zero-network validator for Supabase Session Pooler URLs before handing them to `gbrain init`. Canonical shape verified per gbrain init.ts:266: postgresql://postgres.:@aws-0-.pooler.supabase.com:6543/postgres Rejects direct-connection URLs (db.*.supabase.co:5432) with a distinct exit code 3 and clear IPv6-failure remediation — that's the most common paste mistake users make, so it earns its own UX path rather than a generic "bad URL" error. Never echoes the URL (contains a password) in error messages; tests verify a distinct seed password never appears in stderr on any reject path. Accepts URL from argv[1] or stdin ("-" or no arg). Co-Authored-By: Claude Opus 4.7 (1M context) * test(setup-gbrain): unit tests for supabase-verify + lib.sh secret helper 22 tests. verify: accepts canonical pooler URL (argv + stdin modes), rejects direct-connection URL with exit 3, rejects wrong scheme, wrong port, empty password, missing userinfo, plain 'postgres' user (catches direct-URL paste errors), wrong host, empty URL. Case-insensitive host match. Explicit negative: error messages never echo the URL password. lib.sh read_secret_to_env: reads piped stdin into the named env var, exports to subprocesses, redacted-preview emits masked form on stderr with the seed password absent, rejects invalid var names (lowercase, leading digit, hyphens), rejects missing/unknown flags, secret value never appears on stdout. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(setup-gbrain): add gstack-gbrain-supabase-provision Management API wrapper Four subcommands: list-orgs, create, wait, pooler-url. Built against the verified Supabase Management API shape (Pre-Impl Gate 1): - POST /v1/projects with {name, db_pass, organization_slug, region} — not the original plan's /v1/organizations/{ref}/projects - No `plan` field; subscription tier is org-level per the OpenAPI description ("Subscription Plan is now set on organization level and is ignored in this request") - GET /v1/projects/{ref}/config/database/pooler for pooler config — not /config/database Secrets discipline: SUPABASE_ACCESS_TOKEN (PAT) and DB_PASS read from env only, never from argv (D8 grep test enforces this). `set +x` at the top as a defensive default so debug tracing never leaks secrets. Management API hostname hardcoded to SUPABASE_API_BASE env override — no user-controlled URL portion (SSRF guard). HTTP error paths: 401/403 → exit 3 (auth), 402 → 4 (quota), 409 → 5 (conflict), 429 + 5xx → exponential-backoff retry up to 3 attempts, then exit 8. Wait subcommand polls every 5s until ACTIVE_HEALTHY with a configurable timeout; terminal states (INIT_FAILED, REMOVED, etc.) exit 7 immediately with a clear message. Timeout emits the --resume-provision hint so the skill can recover. Pooler-url constructs the URL locally from db_user/host/port/name + DB_PASS rather than trusting the API response's connection_string field, which is templated with [PASSWORD] rather than the real value. Handles both object and array response shapes, preferring session pool_mode when Supabase returns multiple pooler configs. Co-Authored-By: Claude Opus 4.7 (1M context) * test(setup-gbrain): unit tests for gstack-gbrain-supabase-provision via mock API 22 tests covering D21 HTTP error suite (401/403/402/409/429/5xx) and happy paths for all four subcommands. Every test spins up a Bun.serve mock server bound to SUPABASE_API_BASE so nothing hits the real API. Uses Bun.spawn (async) rather than spawnSync because spawnSync blocks the Bun event loop, which prevents Bun.serve mocks from responding — calls would hit curl's own timeout instead of round-tripping. Verifies: POST body contains organization_slug (not organization_id) and no `plan` field, bearer-token auth header, retry-on-429 with eventual success, exit-8 on persistent 5xx after max retries, wait succeeds on ACTIVE_HEALTHY, exits 7 on INIT_FAILED, exits 6 with --resume-provision hint on timeout, pooler-url builds URL locally from db_user/host/port/name + DB_PASS (not response connection_string template), handles array pooler responses. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(setup-gbrain): add SKILL.md.tmpl — user-facing skill prompt Stitches together every slice built so far (repo-policy, detect, install, lib.sh secret helper, supabase-verify, supabase-provision) into a single interactive flow. Paths: Supabase existing-URL, Supabase auto-provision (D7), Supabase manual, PGLite local, switch (PGLite ↔ Supabase via gbrain migrate wrapped in timeout 180s per D9). Secrets discipline per D8/D10/D11: PAT + DB_PASS + pooler URL all read via read_secret_to_env from lib.sh and handed to gbrain via GBRAIN_DATABASE_URL env, never argv. PAT carries the full D11 scope disclosure before collection and an explicit revocation reminder after success. D12 SIGINT recovery prints the in-flight ref + resume command. D18 MCP registration is scoped honestly to Claude Code — skips with a manual-register hint when `claude` is not on PATH. D6 per-remote trust-triad question (read-write/read-only/deny/skip-for-now) gates repo import; the triad values compose with the D2-eng schema-version policy file so future migrations stay deterministic. Skill runs concurrent-run-locked via mkdir ~/.gstack/.setup-gbrain.lock.d (atomic, same pattern as gstack-brain-sync). Telemetry (D4) payload carries enumerated categorical values only — never URL, PAT, or any postgresql:// substring. --repo, --switch, --resume-provision, --cleanup-orphans shortcut modes documented inline; the skill parses its own invocation args. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(health): integrate gbrain as D6 composite dimension Adds a GBrain row to the /health dashboard rubric with weight 10%. Three sub-signals rolled into one 0-10 score: doctor status (0.5), sync queue depth (0.3), last-push age (0.2). Redistributes when gbrain_sync_mode is off so the dimension stays fair. Weights rebalance: typecheck 25→22, lint 20→18, test 30→28, deadcode 15→13, shell 10→9, gbrain +10 — sums to 100. gbrain doctor --json wrapped in timeout 5s so a hung gbrain never stalls the /health dashboard. Dimension is omitted (not red) when gbrain is not installed — running /health on a non-gbrain machine shouldn't penalize that choice. History-JSONL adds a `gbrain` field. Pre-D6 entries read as null for trend comparison; new tracking starts from first post-D6 run. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(test): add secret-sink-harness for negative-space leak testing (D21 #5) Runs a subprocess with a seeded secret, captures every channel the subprocess could leak through, and asserts the seed never appears. Built per the D1-eng tightened contract: per-run tmp $HOME, four seed match rules (exact + URL-decoded + first-12-char prefix + base64), fd-level stdout/stderr capture via Bun.spawn, post-mortem walk of every file written under $HOME, separate buckets for telemetry JSONL. Reusable: any future skill that handles secrets can import runWithSecretSink and run positive/negative controls against its own bins. The harness itself is ~180 lines of TS with no external deps beyond Bun + node:fs. Out of scope for v1 (documented as follow-ups): subprocess env dump (portable /proc reading), the user's real shell history (bins don't modify it). Co-Authored-By: Claude Opus 4.7 (1M context) * test: secret-sink harness positive controls + real-bin negative controls 11 tests. Positive controls deliberately leak a seed in every covered channel (stdout, stderr, a file under $HOME, the telemetry JSONL path, base64-encoded, first-12-char prefix) and assert the harness catches each one. Without these, a harness that silently under-reports would look identical to a harness that works. Negative controls run real setup-gbrain bins with distinctive seeds: - supabase-verify rejects a mysql:// URL and a direct-connection URL, password never appears in any captured channel - lib.sh read_secret_to_env reads piped stdin, emits only the length, seed value stays invisible - supabase-provision on an auth-failure path fails fast without leaking the PAT to any channel Covers D21 #5 leak harness + uses it to validate D3-eng, D10, D11 discipline end-to-end on the already-shipped bins. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(setup-gbrain): add list-orphans + delete-project subcommands (D20) Powers /setup-gbrain --cleanup-orphans. list-orphans filters the authenticated user's Supabase projects by name prefix (default "gbrain") and excludes the project the local ~/.gbrain/config.json currently points at, so only unclaimed gbrain-shaped projects come back. Active-ref detection parses the pooler URL's user portion (postgres.:@...). delete-project is a thin DELETE /v1/projects/{ref} wrapper with no confirmation of its own — the skill's UI layer owns the per-project confirm AskUserQuestion loop. Keeps responsibilities clean: the bin manages HTTP; the skill manages user intent. Both subcommands reuse the existing api_call retry+backoff and the same PAT discipline (env only, never argv). Co-Authored-By: Claude Opus 4.7 (1M context) * test(setup-gbrain): list-orphans active-ref filtering + delete-project 404 6 new tests bringing the supabase-provision suite to 28: list-orphans: - Filters to gbrain-prefixed projects, excludes the active-ref derived from ~/.gbrain/config.json's pooler URL - Treats all gbrain-prefixed projects as orphans when no config exists (first run on a new machine) - Respects custom --name-prefix for users who named their brain something else delete-project: - Happy path sends DELETE /v1/projects/ and returns {deleted_ref} - 404 surfaces cleanly (exit 2, "404" in stderr) - Missing positional rejected with exit 2 Uses per-test tmpdir HOME with a stubbed ~/.gbrain/config.json so active-ref extraction runs against deterministic fixtures. Co-Authored-By: Claude Opus 4.7 (1M context) * chore: regenerate setup-gbrain SKILL.md after main merge * chore: bump version and changelog (v1.12.0.0) Ships /setup-gbrain and its supporting infrastructure end-to-end: per-remote trust policy, installer with PATH-shadow guard, shared secret-read helper, structural URL verifier, Supabase Management API wrapper, /health GBrain dimension, secret-sink test harness. 100 new tests across 5 suites, all green. Three pre-existing test failures noted as P0 in TODOS.md. Co-Authored-By: Claude Opus 4.7 (1M context) * docs: add USING_GBRAIN_WITH_GSTACK.md + update README for /setup-gbrain README changes: - Rewrote the "Cross-machine memory with GBrain sync" section into "GBrain — persistent knowledge for your coding agent." Covers the three /setup-gbrain paths (Supabase existing URL, auto-provision, PGLite local), MCP registration, per-remote trust triad, and the (still-separate) memory sync feature. - Added /setup-gbrain row to the skills table pointing at the full guide. - Added /setup-gbrain to both skill-list install snippets. - Added USING_GBRAIN_WITH_GSTACK.md to the Docs table. New doc (USING_GBRAIN_WITH_GSTACK.md): - All three setup paths with trust-surface caveats - MCP registration details (and honest Claude-Code-v1 scoping) - Per-remote trust triad semantics + how to change a policy - Switching engines (PGLite ↔ Supabase) via --switch - GStack memory sync + its relationship to the gbrain knowledge base - /setup-gbrain --cleanup-orphans for orphan Supabase projects - Full command + flag reference, every bin helper, every env var - Security model: what's enforced in code, what's enforced by the leak harness, and the honest limits of v1 - Troubleshooting: PATH shadowing, direct-connection URL reject, auto-provision timeout, stale lock, policy file hand-edits, migrate hang - Why-this-design section explaining the non-obvious choices Co-Authored-By: Claude Opus 4.7 (1M context) * fix(brain-sync): secret scanner now catches Bearer-prefixed auth tokens in JSON The bearer-token-json regex value charset was [A-Za-z0-9_./+=-]{16,}, which does NOT permit spaces. Real HTTP auth headers embed the scheme name with a literal space — "Bearer " — so the value portion actually starts with "Bearer " and the existing regex couldn't match. Result: any JSON blob containing "authorization":"Bearer ..." would slip past the scanner and sync to the user's private brain repo with the bearer token inline. Added optional (Bearer |Basic |Token )? prefix in front of the value charset. Now matches the common auth-scheme forms without broadening the matcher to tolerate arbitrary whitespace (which would false-positive on lots of benign JSON). Verified against 5 positive cases (bearer-in-json, clean bearer, apikey no-prefix, token with Bearer, password no-prefix) + 3 negative cases (too-short tokens, non-secret field names like username, random JSON). This closes the P0 security regression first noticed during v1.12.0.0 /ship. brain-sync.test.ts now passes all 7 secret-scan fixtures. Co-Authored-By: Claude Opus 4.7 (1M context) * test: mock-gh integration tests for gstack-brain-init auto-create path 8 tests covering the gh-repo-create happy path that had zero coverage before. Existing brain-sync.test.ts always passes --remote to bypass gh entirely, so the interactive default ("press Enter, we'll run gh repo create for you") was shipping on trust. Test strategy: write a bash stub for gh that records every call into a file, then run gstack-brain-init with that stub on PATH. Assertions verify: gh auth status is checked, gh repo create fires with the computed gstack-brain- default name + --private + --source flags, fall-through to gh repo view when create reports already-exists, user-provided URL bypasses gh entirely, gh-not-on-path and gh-not-authed branches both prompt for URL, --remote flag short-circuits all gh calls, conflicting-remote re-runs exit 1 with a clear message. No real GitHub, no live auth. Gate tier — runs on every commit. Co-Authored-By: Claude Opus 4.7 (1M context) * test(e2e): privacy-gate AskUserQuestion fires from preamble (periodic tier) Two periodic-tier E2E tests exercising the preamble's privacy gate end-to-end via the Agent SDK + canUseTool. Previously uncovered: - Positive: stages a fake gbrain on PATH + gbrain_sync_mode_prompted=false in config, runs a real skill, intercepts tool-use. Asserts the preamble fires a 3-option AskUserQuestion matching the canonical prose ("publish session memory" / "artifact" / "decline") and does NOT fire a second time in the same run (idempotency within session). - Negative: same staging but prompted=true. Asserts the gate stays silent even with gbrain detected on the host. Registered in test/helpers/touchfiles.ts as `brain-privacy-gate` (periodic) with dependency tracking on generate-brain-sync-block.ts, the three gstack-brain-* bins, gstack-config, and the Agent SDK runner. Diff-based selection re-runs the E2E when any of those change. Cost: ~$0.30-$0.50 per run. Only fires under EVALS=1 EVALS_TIER=periodic; gate tier stays free. Co-Authored-By: Claude Opus 4.7 (1M context) * docs: update TODOS for bearer-json fix + new brain-sync test coverage Moves the bearer-json secret-scan regression from the P0 "pre-existing failures" block into the Completed section with full context on the fix, the mock-gh tests, the E2E privacy-gate tests, and the touchfile registration. Remaining P0s are the GSTACK_HOME config-isolation bug and the stale Opus 4.7 overlay pacing assertion, both unrelated. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(test): E2E privacy gate — ambient env + skill-file prompt Two fixes to get the E2E actually running end-to-end (first attempt failed at the SDK auth step, second at the assertion step): 1. Don't pass an explicit `env:` object to runAgentSdkTest. The SDK's auth pipeline misses ANTHROPIC_API_KEY when env is supplied as an object (verified against the plan-mode-no-op test, which passes no env and auths cleanly). Mutate process.env before the call instead, and restore the originals in finally so other tests don't inherit the ambient mutation. 2. The "Run /learn with no arguments" user prompt was too narrow — the model reduced it to a direct action and skipped the preamble privacy-gate directives entirely, so zero AskUserQuestions fired. Mirror the plan-mode-no-op pattern: point the model at the skill file on disk and ask it to follow every preamble directive. Bumped maxTurns from 6 to 10 to give the preamble room to execute. Verified both tests pass under `EVALS=1 EVALS_TIER=periodic bun test test/skill-e2e-brain-privacy-gate.test.ts` against a real ANTHROPIC_API_KEY. Cost per run: ~$0.30-$0.50 per test. Co-Authored-By: Claude Opus 4.7 (1M context) * docs(CLAUDE.md): source ANTHROPIC/OPENAI keys from ~/.zshrc for paid evals Conductor workspaces don't inherit the interactive shell env, so both API keys are absent from the default process env even though they're set in ~/.zshrc. Documents the source-from-zshrc pattern (grep + eval, never echo the value) plus the Agent SDK gotcha: do NOT pass env as an object to runAgentSdkTest — mutate process.env ambiently and restore in finally. Discovered this during the brain-privacy-gate E2E. First run failed at SDK auth with 401; second failed because explicit env handoff bypassed the SDK's own auth routing. Fix pattern now codified so the next paid-eval session in a Conductor workspace doesn't hit the same two dead ends. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 54 + CLAUDE.md | 20 + README.md | 53 +- TODOS.md | 26 + USING_GBRAIN_WITH_GSTACK.md | 291 +++++ VERSION | 2 +- bin/gstack-brain-sync | 7 +- bin/gstack-gbrain-detect | 112 ++ bin/gstack-gbrain-install | 183 +++ bin/gstack-gbrain-lib.sh | 101 ++ bin/gstack-gbrain-repo-policy | 227 ++++ bin/gstack-gbrain-supabase-provision | 447 +++++++ bin/gstack-gbrain-supabase-verify | 126 ++ health/SKILL.md | 63 +- health/SKILL.md.tmpl | 63 +- package.json | 2 +- setup-gbrain/SKILL.md | 1447 +++++++++++++++++++++ setup-gbrain/SKILL.md.tmpl | 449 +++++++ test/gbrain-detect-install.test.ts | 298 +++++ test/gbrain-lib-verify.test.ts | 257 ++++ test/gbrain-repo-policy.test.ts | 271 ++++ test/gbrain-supabase-provision.test.ts | 556 ++++++++ test/gstack-brain-init-gh-mock.test.ts | 234 ++++ test/helpers/secret-sink-harness.ts | 212 +++ test/helpers/touchfiles.ts | 5 + test/secret-sink-harness.test.ts | 216 +++ test/skill-e2e-brain-privacy-gate.test.ts | 227 ++++ 27 files changed, 5889 insertions(+), 60 deletions(-) create mode 100644 USING_GBRAIN_WITH_GSTACK.md create mode 100755 bin/gstack-gbrain-detect create mode 100755 bin/gstack-gbrain-install create mode 100644 bin/gstack-gbrain-lib.sh create mode 100755 bin/gstack-gbrain-repo-policy create mode 100755 bin/gstack-gbrain-supabase-provision create mode 100755 bin/gstack-gbrain-supabase-verify create mode 100644 setup-gbrain/SKILL.md create mode 100644 setup-gbrain/SKILL.md.tmpl create mode 100644 test/gbrain-detect-install.test.ts create mode 100644 test/gbrain-lib-verify.test.ts create mode 100644 test/gbrain-repo-policy.test.ts create mode 100644 test/gbrain-supabase-provision.test.ts create mode 100644 test/gstack-brain-init-gh-mock.test.ts create mode 100644 test/helpers/secret-sink-harness.ts create mode 100644 test/secret-sink-harness.test.ts create mode 100644 test/skill-e2e-brain-privacy-gate.test.ts 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)