* feat: OAuth 2.1 schema tables + shared token utilities
Add oauth_clients, oauth_tokens, oauth_codes tables to both PGLite and
Postgres schemas. Migration v5 creates tables for existing databases.
PGLite now includes auth infrastructure (access_tokens, mcp_request_log,
OAuth tables) because `serve --http` makes it network-accessible.
Extract hashToken() and generateToken() to src/core/utils.ts for DRY
reuse across auth.ts and oauth-provider.ts.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: GBrainOAuthProvider — MCP SDK OAuthServerProvider implementation
Implements OAuthServerProvider backed by raw SQL (PGLite or Postgres).
Supports client credentials, authorization code with PKCE, token refresh
with rotation, revocation, and legacy access_tokens fallback.
Key decisions from eng review:
- Uses raw SQL connection, not BrainEngine (OAuth is infrastructure)
- All tokens/secrets SHA-256 hashed before storage
- Legacy tokens grandfathered as read+write+admin
- sweepExpiredTokens() wrapped in try/catch (non-blocking startup)
- Client credentials: no refresh token per RFC 6749 4.4.3
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: scope + localOnly annotations on all 30 operations
Add AuthInfo, scope ('read'|'write'|'admin'), and localOnly fields to
Operation interface. Per-operation audit:
- 14 read ops, 9 write ops, 2 admin ops, 4 admin+localOnly ops
- sync_brain, file_upload, file_list, file_url: admin + localOnly
- Scope enforcement happens in serve-http.ts before handler dispatch
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: HTTP MCP server with OAuth 2.1 + 27 OAuth tests
gbrain serve --http starts Express 5 server with:
- MCP SDK mcpAuthRouter (authorize, token, register, revoke endpoints)
- Custom client_credentials handler (SDK doesn't support CC grant)
- Bearer auth + scope enforcement on /mcp tool calls
- Admin dashboard auth via HTTP-only cookie + bootstrap token
- SSE live activity feed at /admin/events
- DCR default OFF (--enable-dcr to enable)
- Rate limiting on /token (50/15min)
- localOnly operations excluded from HTTP
CLI: gbrain serve --http [--port 3131] [--token-ttl 3600] [--enable-dcr]
Dependencies: express@5.2.1, express-rate-limit@7.5.1, cors@2.8.6
SDK pinned to exact 1.29.0 (was ^1.0.0)
27 new tests covering OAuth provider, scope enforcement, auth code flow,
refresh rotation, token revocation, legacy fallback, and sweep.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: React admin dashboard — 7 screens, dark theme, Krug-designed
Admin SPA at /admin with client-side routing (#login, #dashboard,
#agents, #log). Built with Vite + React, served from admin/dist/.
Screens:
- Login: one field, one button, zero happy talk
- Dashboard: metrics bar, SSE live activity feed, token health panel
- Agents: table with scopes/badges, + Register Agent button
- Register: modal form (name, scopes), 3 mindless choices
- Credentials: full-screen modal, copy buttons, download JSON, warning
- Request Log: paginated table (50/page), time-relative timestamps
- Agent Detail: slide-out drawer, config export tabs (Perplexity/Claude/JSON)
Design tokens: #0a0a0f bg, Inter + JetBrains Mono, 4-32px spacing.
Build: bun run build:admin (Vite, 65KB gzipped).
Admin API: /admin/api/register-client endpoint for dashboard registration.
SPA serving: Express static + index.html fallback for client-side routing.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* chore: add admin SPA lockfile
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* chore: bump version and changelog (v1.0.0.0)
Milestone release: multi-agent GBrain with OAuth 2.1, HTTP server,
and React admin dashboard. See CHANGELOG.md for details.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* docs: update project documentation for v1.0.0.0
Sync README, CLAUDE.md, and docs/mcp/ with the OAuth 2.1 + HTTP server
+ admin dashboard surface that shipped in v1.0.0.0.
- README.md: new "Remote MCP with OAuth 2.1" section covering
gbrain serve --http, admin dashboard, scoped operations, legacy
bearer fallback; add serve --http + auth notes to the commands
reference.
- CLAUDE.md: add src/commands/serve-http.ts, src/core/oauth-provider.ts,
admin/ directory as key files; document scope + localOnly additions
to Operation contract; add oauth.test.ts (27 cases) to the test list;
add v1.0.0 key-commands section clarifying that OAuth client
registration is via the /admin dashboard or SDK (no CLI subcommand).
- docs/mcp/DEPLOY.md: promote --http as the recommended remote path,
add OAuth 2.1 Setup section, list ChatGPT in supported clients,
remove the "not yet implemented" footer.
- docs/mcp/CHATGPT.md (new): unblocks the P0 TODO. Full ChatGPT
connector setup via OAuth 2.1 + PKCE.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat: wire gbrain auth subcommand with OAuth register-client
Previously auth.ts was a standalone script invoked via
`bun run src/commands/auth.ts`. CHANGELOG and README documented
`gbrain auth ...` commands that didn't actually work.
- Export `runAuth(args)` from auth.ts (keeps standalone entry intact
via `import.meta.url === file://${process.argv[1]}` check)
- Add `auth` to CLI_ONLY + dispatch in handleCliOnly
- New subcommand `gbrain auth register-client <name> [--grant-types]
[--scopes]` wraps GBrainOAuthProvider.registerClientManual
- Lazy DB check: only subcommands that need DATABASE_URL error out
Now the documented CLI flow works end to end:
gbrain auth register-client perplexity --grant-types client_credentials --scopes "read write"
gbrain serve --http --port 3131
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* docs: reflect wired gbrain auth register-client CLI
After /ship, the doc subagent wrote docs assuming `gbrain auth
register-client` did not exist (it said so explicitly in CLAUDE.md:184).
A follow-up commit (c4a86ce) wired it into src/cli.ts + src/commands/auth.ts.
These docs were now contradicting reality.
- CLAUDE.md: removed "There is no gbrain auth register-client CLI
subcommand" claim, documented the three registration paths
(CLI / dashboard / SDK).
- README.md: replaced `bun run src/commands/auth.ts` hint with
`gbrain auth create|list|revoke|test` and `gbrain auth register-client`.
- docs/mcp/DEPLOY.md: added CLI registration example above the
programmatic example.
- TODOS.md: moved "ChatGPT MCP support (OAuth 2.1)" P0 item to
Completed with v1.0.0.0 completion note. Closes the P0 that had been
blocking the "every AI client" promise since v0.6.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix: enable RLS on OAuth tables + loosen v24-exact test assertion
CI Tier 1 (Mechanical) was failing on 4 E2E tests after the v0.18.1 RLS
hardening landed on master (PR #343). Our v25 oauth_infrastructure migration
adds 3 new public tables (oauth_clients, oauth_tokens, oauth_codes) but
didn't enable RLS, so gbrain doctor's new check flagged them and the
"RLS on every public table" assertion failed.
Fixes:
- src/schema.sql: ALTER TABLE ... ENABLE ROW LEVEL SECURITY for the 3 OAuth
tables inside the existing BYPASSRLS-gated DO block (fresh installs).
- src/core/migrate.ts v25: append a BYPASSRLS-gated DO block after the OAuth
CREATE TABLE statements (existing installs on upgrade). Mirrors the v24
rls_backfill gating pattern — RAISE WARNING if the current role lacks
BYPASSRLS, so migrations don't silently lock the operator out.
- src/core/schema-embedded.ts: regenerated via `bun run build:schema`.
- test/e2e/mechanical.test.ts: one unrelated v24 test asserted the post-
migration version equals exactly '24'. That breaks when any later
migration exists (like our v25). Relaxed to `>= 24` since the test's
intent is "v24 didn't abort the chain", not "v24 is the final version".
Verified locally: 78/78 E2E tests pass against real Postgres 16 + pgvector.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* chore: regenerate llms-full.txt for v1.0.0 docs
CI test/build-llms.test.ts > committed llms.txt + llms-full.txt match
current generator output failed. The committed llms-full.txt was built
before the v1.0.0 doc updates landed (OAuth 2.1 README section, new
docs/mcp/CHATGPT.md, CLAUDE.md serve-http references, etc.), so the
regen-drift guard flagged it.
Ran `bun run build:llms`. llms.txt is unchanged (skinny index still
matches); llms-full.txt picks up 166 net-new lines of bundled content.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* connected-gbrains PR 0 — minimal runtime (mounts, registry, aggregated RESOLVER) (#372)
* feat(mounts): connected-gbrains PR 0 foundation — registry + resolver + CLI
Lays the foundation for connected gbrains (v0.19.0) per the approved plan.
This is PR 0 — minimal runtime for direct-transport, path-mounted brains.
What this slice ships:
- src/core/brain-registry.ts — keyed BrainRegistry with lazy engine init,
schema-validated mounts.json loader, DuplicateMountPathError (load-bearing
identity check per Codex finding #9 correction), UnknownBrainError with
actionable available-id list. Pure: no AsyncLocalStorage, no singleton
mutation. ~280 LOC.
- src/core/brain-resolver.ts — 6-tier brain-id resolution mirroring
v0.18.0's source-resolver.ts so agents learn ONE mental model:
1. --brain <id> 2. GBRAIN_BRAIN_ID env 3. .gbrain-mount dotfile
4. longest-path match over registered mounts 5. (reserved v2 default)
6. 'host' fallback
Orthogonal to --source: --brain picks which DB, --source picks the repo
within that DB. Corruption-resistant: mounts.json load failures fall
through to 'host' instead of breaking every CLI invocation.
- src/commands/mounts.ts — `gbrain mounts add|list|remove` (direct transport
only). Validates on add (path exists on disk, id regex, no dupes). WARNS
but does not block on same db_url/db_path across ids (teams may
legitimately alias a remote brain). Password redaction in list output.
Atomic write via temp+rename. 0600 perms. PR 1 adds pin/sync/enable;
PR 2 adds --mcp-url + OAuth.
- src/cli.ts — wires `gbrain mounts` into handleCliOnly (no DB required
for the config-only subcommands).
- test/brain-registry.test.ts (28 cases): schema validation across every
malformed-input branch, ALS-free resolution, duplicate id + path detection,
disabled-mount exclusion, UnknownBrainError context.
- test/brain-resolver.test.ts (22 cases): priority order (explicit > env >
dotfile > path-prefix > fallback), dotfile walk-up, malformed dotfile
recovery, longest-prefix match, sibling-path false-positive guard,
loader-failure defense.
- test/mounts-cli.test.ts (17 cases): parseAddArgs surface, redactUrl,
atomic write, add/list/remove roundtrip via temp HOME.
67 new tests, all green. Typecheck clean. Depends on mcp-key-mgmt (base
branch) for the OAuth/scope annotations that PR 2 will leverage.
Next in this branch: PR 0 still needs (a) the deep host-brain-bias audit
(postgres-engine internal singleton fallback + a few operations.ts
callers), (b) OperationContext threading to make ctx.brainId populated at
dispatch, (c) composeResolvers + composeManifests, (d) aggregated
~/.gbrain/mounts-cache/ for host-agent runtime ownership.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(mounts): brains-and-sources mental model + agent routing convention
Two orthogonal axes organize GBrain knowledge. Users AND agents need to
understand both, or queries misroute silently.
--brain → WHICH DATABASE (host + mounts)
--source → WHICH REPO IN DB (v0.18.0 sources: wiki, gstack, ...)
Both axes use the same 6-tier resolution (explicit > env > dotfile >
path-prefix > default > fallback), so learning one teaches both.
Ships:
- docs/architecture/brains-and-sources.md — canonical mental model doc.
Covers four topologies with ASCII diagrams:
1. Single-person developer (one brain, one source)
2. Personal brain with multiple repos (one brain, N sources)
3. Personal + one team brain mount (2 brains)
4. Senior user with multiple team memberships (N mounted team brains
alongside personal) — the CEO-class topology
Explicit "when to move each axis" decision table. Generic example names
throughout per the project's privacy rule.
- skills/conventions/brain-routing.md — agent-facing decision table.
Rules for when to switch brain (team-owned question, explicit name,
data owner changes) vs switch source (working in a repo, topic scoped
to one repo). Cross-brain federation is latent-space only in v0.19 —
the agent fans out; the DB never does. Anti-patterns listed: silent
brain jumps, writing to host when data is team-owned, missing brain
prefix in citations, ignoring .gbrain-mount dotfiles.
- CLAUDE.md — adds "Two organizational axes (read this first)" section
at the top pointing at both new docs.
- AGENTS.md — adds brains-and-sources.md + brain-routing.md to the
"read this order" (positions 3 and 4, before RESOLVER.md).
- skills/RESOLVER.md — adds brain-routing.md to the Conventions section
so it appears alongside quality.md, brain-first.md, subagent-routing.md.
No code changes. Pre-existing check-resolvable warnings unchanged (2
warnings on base unrelated to this work). 67 PR-0 tests still green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(mounts): thread brainId through OperationContext + subagent chain
PR 0 plumbing for connected gbrains. Adds an optional brainId field that
identifies which database an operation targets and ensures subagents
inherit the parent job's brain instead of process-wide defaults. No
dispatch-path changes in this commit — that is PR 1 (registry wiring at
MCP + CLI entry points). The fields exist so callers can set them now
and downstream code respects them.
Changes:
- src/core/operations.ts: OperationContext grows `brainId?: string`.
Optional for back-compat. 'host' is the implicit default when absent.
Orthogonal to v0.18.0's source_id (source = which repo within the
brain, brain = which database). See docs/architecture/brains-and-sources.md.
- src/core/minions/types.ts: SubagentHandlerData gains `brain_id?: string`.
Parent jobs set this when submitting a child subagent to lock the
child into a specific brain. Omitted = host (unchanged behavior).
- src/core/minions/handlers/subagent.ts: buildBrainTools call site
reads data.brain_id and passes it through. Child subagents spawned
from this handler will see the same brainId unless they override in
their own data.
- src/core/minions/tools/brain-allowlist.ts: BuildBrainToolsOpts +
OpContextDeps grow brainId; buildOpContext stamps it on every
OperationContext the subagent builds for tool calls. Addresses Codex
finding #6 (brain-allowlist hardwired parent config without brain
awareness, so switching brain only in subagent.ts was not enough).
Tests: 166 affected tests green (subagent suite + minions + brain
registry + resolver). Typecheck clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(mounts): composeResolvers + composeManifests + aggregated cache
The runtime ownership seam for connected gbrains (Codex finding #3 from
plan review): check-resolvable.ts VALIDATES RESOLVER.md; it does not
DISPATCH skills. Host agents (Wintermute/OpenClaw/Claude Code) read
skills/RESOLVER.md directly to route user requests. Without an aggregated
resolver, mounted team brains cannot contribute skills to the host
agent's routing table.
This commit adds the aggregation:
- src/core/mounts-cache.ts (NEW): pure composeResolvers + composeManifests
functions plus filesystem writers for ~/.gbrain/mounts-cache/. The
aggregated files carry every host skill plus every mount skill,
namespace-prefixed (e.g. `yc-media::ingest`). Host skills always beat
a same-named mount skill (locked decision 1); bare-name collisions
between two mounts surface as structured ambiguity info so doctor can
warn (PR 1).
Also addresses Codex finding #8: manifests compose alongside the
resolver, else doctor conformance breaks on remote skills.
- src/commands/mounts.ts: refreshMountsCache() called on `mounts add`
and `mounts remove` (the latter clearing the cache entirely when the
last mount goes away). Uses findRepoRoot() to locate the host skills
dir; skips with a stderr note when run outside a gbrain repo so the
user isn't confused by a "cache not refreshed" error in the wrong
cwd.
- test/mounts-cache.test.ts (NEW): 23 unit tests covering empty world,
host-only, single mount, two-mount ambiguity, host-shadows-mount,
disabled mount excluded, missing RESOLVER.md is a no-op, manifest
composition with same-name collision, render shape, atomic rewrite,
clear on missing dir.
Output format for ~/.gbrain/mounts-cache/RESOLVER.md adds a Brain column
so host agents can see which brain each trigger routes to at a glance,
plus Shadows and Ambiguous sections when those conditions exist.
Tests: 90 PR 0 tests green (brain-registry + resolver + mounts-cache +
mounts-cli). Full suite regression pending in task 11.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(mounts): force instance-level pool for mount brains + CI guard
Closes the silent-singleton-share bug Codex flagged as finding #1 from
the plan review: two direct-transport mounts with different Postgres
URLs would both fall through postgres-engine.ts's `get sql()` getter to
db.getConnection() and quietly share whichever singleton connected
first. Your yc-media writes end up in garrys-list or vice versa. No
error at the call site — just wrong data.
The fix:
- src/core/brain-registry.ts: initMountBrain now passes poolSize when
calling engine.connect(). That forces postgres-engine.ts:33-60 down
the instance-level path (setting this._sql) instead of the module
singleton path (calling db.connect). Hard-coded 5 for PR 0 — per-mount
override is PR 1. PGLite ignores poolSize (no pool concept), so this
is Postgres-specific.
Host brain still uses the singleton path via initHostBrain (unchanged).
That is fine for PR 0: the singleton is "the host's one connection"
by definition. PR 1 removes the singleton entirely once every CLI
command is engine-injectable.
- scripts/check-no-legacy-getconnection.sh (NEW): CI grep guard against
new db.getConnection() / db.connect() calls landing in src/core/ or
src/commands/ (the multi-brain dispatch surface). Has an explicit
ALLOWED list grandfathering today's legitimate callers, each marked
"PR 1 refactors" so the list shrinks over time. Skips comment lines
so the grep doesn't trip on doc references to the old pattern.
- package.json: scripts.test chains the new guard after the existing
check-jsonb-pattern + check-progress-to-stdout guards. `bun run test`
now fails the build on singleton regression.
Tests: 295 affected pass (registry, resolver, mounts-cache, mounts-cli,
minions, pglite-engine). Typecheck clean. CI guard reports "ok: no new
singleton callers" on current tree.
Left for PR 1: remove the singleton fallback in postgres-engine.ts's
`get sql()` entirely; refactor src/commands/doctor.ts, files.ts,
repair-jsonb.ts, serve-http.ts, init.ts, and the 3 localOnly ops in
operations.ts (file_list, file_upload, file_url) to accept ctx.engine
explicitly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(mounts): codex review findings — namespace survives shadow + atomic tmp names + honest PR 0 docstrings
Codex outside-voice review on PR #372 found 5 issues. Real bugs fixed, overclaims
rewritten. Details:
P2 (real bug): composeResolvers and composeManifests were silently dropping
mount entries when a host skill shared the short name, which made the
namespace-qualified form `<mount>::<skill>` unreachable once host defined
the same short name. That defeated the entire namespace-disambiguation
model — if host had `ingest`, no mount could ship an `ingest` skill even
with explicit `yc-media::ingest`. Fix: always keep namespace-qualified
mount entries in the composed output. Shadow tracking moves to metadata
(`shadows[]`) that doctor can warn on, but never drops routing.
Before: host ingest + yc-media ingest → only 1 entry (host), yc-media::ingest unreachable
After: host ingest + yc-media ingest → 2 entries: bare `ingest` = host, `yc-media::ingest` = mount
Verified live: gbrain mounts add of a mount with `ingest` now shows
`team-demo::ingest` alongside host `ingest` in the aggregated manifest.
P1 (real bug): writeMountsFile + writeMountsCache used fixed `.tmp`
filenames. Two concurrent `gbrain mounts add` invocations (e.g. from
parallel terminals or CI) would clobber each other's temp file and
one writer's update would be lost. Fix: tmp filenames include
`process.pid + random suffix` so every writer has its own scratch file.
The atomic rename is self-contained per-writer. (Full lock + read-modify-
write safety deferred to PR 1 under `gbrain mounts sync --lock`.)
P1 (honesty): `SubagentHandlerData.brain_id` +
`BuildBrainToolsOpts.brainId` docstrings claimed child jobs inherit the
parent's brain and brain tools target the resolved brain. True for the
`ctx.brainId` field only — `ctx.engine` is still the worker's base
engine at dispatch time because `buildOpContext` doesn't yet do the
registry lookup, and `gbrain agent run` doesn't yet accept `--brain` to
populate the field on submission. Rewrote both docstrings to state the
PR 0 behavior explicitly (field plumbed, engine routing is PR 1) so
nobody reads the code thinking multi-brain subagents already work.
Also cleaned up two `require('fs')` runtime imports left over from the
initial PR — swapped for ESM named imports (renameSync). Pre-existing
style issue surfaced by the self-review pass.
Tests: 90 PR-0 tests pass. Updated two shadow-related test cases to
assert the corrected semantics (both entries survive, host wins bare
name, namespace form routes to mount).
Not fixed in this commit (documented as known PR 0 limitations):
- `file_list` / `file_upload` / `file_url` in operations.ts still hit the
singleton (localOnly + admin, never reachable from HTTP MCP — safe in
practice, refactor in PR 1 alongside command-level cleanups).
- writeMountsCache's two-file swap (RESOLVER.md + manifest.json) is not
atomic across files; readers can briefly observe mismatched pairs.
Acceptable because the cache is recomputable at any time from
mounts.json. Generation-directory swap is PR 1 work.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(tests): bump hook timeouts for 21-migration PGLite init under full-suite load
Root cause of 19 pre-existing full-suite flakes (CHANGELOG v0.18.0 noted
"17 pre-existing master timeouts"): every PGLite test does
beforeAll/beforeEach(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema(); // runs 21 migrations through v0.18.2
});
In isolation this takes ~5s. Under full-suite contention (128 files,
process-shared FS and CPU) it exceeds bun's default 5000ms hook timeout,
beforeEach times out, engine stays undefined, then afterEach crashes
with `TypeError: undefined is not an object (evaluating 'engine.disconnect')`.
That single hook failure reports as the whole test "failing" even though
the test body never executed, which is why the failure count sometimes
looked inflated compared to the number of genuinely-broken tests.
Fix applied across 7 test files:
- Raise setup hook timeout to 30_000 (6x the default) — gives migration
init enough headroom even under worst-case load without masking real
regressions in a post-migration test.
- Raise teardown hook timeout to 15_000 — engine.disconnect() is usually
fast but can stall when PGLite's WASM runtime is still completing a
migration at shutdown.
- Add `if (engine) await engine.disconnect()` guard so afterEach doesn't
double-fault when beforeEach already failed. This was the source of
the opaque "(unnamed)" failures — they were disconnect crashes,
not test-body failures.
Files:
test/dream.test.ts (5 beforeEach + 5 afterEach blocks)
test/orphans.test.ts (1 pair)
test/brain-allowlist.test.ts (1 pair)
test/oauth.test.ts (1 pair)
test/extract-db.test.ts (1 pair)
test/multi-source-integration.test.ts (1 pair)
test/core/cycle.test.ts (1 pair)
Results on the merged PR 0 branch:
Before: 2175 pass / 20 fail / 3 errors
After: 2281 pass / 0 fail / 0 errors (+106 tests running that
were previously blocked
by the timed-out hooks)
No changes to production code. No test assertions changed. Just
timeout-bump + null-guard discipline that should have been in these
hooks from the start. The real longer-term fix is reusing an engine
across tests where possible (brain-allowlist.test.ts already does this
via beforeAll+DELETE-pages pattern), but that's per-file structural
work — out of scope for this cleanup.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: regenerate llms-full.txt for brains-and-sources + brain-routing docs
The test/build-llms.test.ts test validates that the committed llms.txt
and llms-full.txt match the current generator output. PR 0 added
docs/architecture/brains-and-sources.md content paths and updated
CLAUDE.md + skills/RESOLVER.md in earlier commits, but the generated
bundle file wasn't regenerated alongside. This caused one of the 20
fails we chased down today — a straight content mismatch, not a runtime
bug. Running `bun run build:llms` picks up the new section content so
the bundle matches the sources again.
No functional change. Only the compiled doc bundle.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Bump version 1.0.0.0 → 0.22.0
OAuth + admin dashboard is meaningful but doesn't quite warrant the
major-version reset to 1.0. Renumber as v0.22.0, slotting cleanly above
master's v0.21.0 (Cathedral II).
Touched:
- VERSION, package.json: 1.0.0.0 → 0.22.0
- CHANGELOG.md: heading + "BEFORE/AFTER v1.0" table + "To take advantage"
+ "pre-v1.0" all renamed. Narrative voice unchanged otherwise.
- TODOS.md: ChatGPT MCP completion stamp updated to v0.22.0 (2026-04-25).
- CLAUDE.md, README.md, docs/mcp/{DEPLOY,CHATGPT}.md, src/schema.sql,
src/core/schema-embedded.ts: every reader-facing v1.0.0 reference
rewritten to v0.22.0 / pre-v0.22 in the same place.
- llms-full.txt: regenerated to match.
Slug-test occurrences of "v1.0.0" (`test/slug-validation.test.ts`,
`test/file-upload-security.test.ts`) and the `HOMEBREW_FOR_PERSONAL_AI`
roadmap reference to a future v1.0 vision left intact — those are
unrelated to this branch's release version.
Typecheck clean. cli + oauth + slug + file-upload tests pass (106 tests).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* v0.26.0 fix: 4 security findings from /cso pass + version bump
Bumped 0.22.0 → 0.26.0 to slot above master's v0.21 chain with headroom
for v0.23/0.24/0.25 to ship from master between now and merge.
Security fixes (all from CSO finding writeups):
#1 cookie-parser middleware — admin dashboard auth was silently broken.
Express 5 has no built-in cookie parsing; req.cookies was always
undefined, so /admin/login set the cookie but every subsequent admin
API call returned 401. Added cookie-parser@^1.4.7 + @types/cookie-parser
as direct + dev deps. app.use(cookieParser()) wired before CORS.
#2 + #3 TOCTOU races — exchangeAuthorizationCode and exchangeRefreshToken
used SELECT-then-DELETE, letting concurrent requests with the same
code/refresh both pass the SELECT before either ran DELETE, both
issuing token pairs. Switched to atomic DELETE...RETURNING. RFC 6749
§10.5 (codes) + §10.4 (refresh detection) violations closed. Added
regression tests that fire 10 concurrent exchanges and assert exactly
one wins — both pass.
#5 pgArray escape + DCR redirect_uri validation — pgArray() did
`arr.join(',')` with no escaping, so an element containing a comma
would be parsed by Postgres as TWO array elements. With --enable-dcr
on, this could smuggle a second redirect_uri into a registered client
and steal auth codes. Now every element is double-quoted with `"` and
`\` escaped. Added validateRedirectUri() per RFC 6749 §3.1.2.1:
redirect_uris must be https:// or loopback (localhost / 127.0.0.1).
Wired into the DCR registerClient path; CLI registration trusts the
operator and bypasses. Regression test confirms a comma-in-URI element
round-trips as 1 element, not 2.
#6 --public-url flag — issuerUrl was hardcoded to http://localhost:{port}.
Behind reverse proxies / ngrok / production deploys, the issuer claim
in tokens wouldn't match the discovery URL clients hit (RFC 8414 §3.3).
New --public-url URL flag on `gbrain serve --http`, propagates through
serve.ts → serve-http.ts → ServeHttpOptions.publicUrl → issuerUrl.
Startup banner surfaces the configured issuer.
Findings #4 (admin requests filter dead code), #7 (admin register-client
hardcoded grant_types), #8 (legacy token grandfathering posture) are
documentation / minor functional fixes and are deferred per user direction.
Tests: oauth.test.ts now 34 cases (was 27). 7 new:
- single-use TOCTOU regression (10 concurrent code exchanges)
- single-use TOCTOU regression (10 concurrent refresh exchanges)
- redirect_uri http://localhost passes
- redirect_uri https://example.com passes
- redirect_uri http://example.com (non-loopback plaintext) rejected
- redirect_uri non-URL rejected
- redirect_uri with embedded comma stored as single element
Files:
- VERSION, package.json: 0.22.0 → 0.26.0
- CHANGELOG.md: heading + table + "To take advantage" + "pre-v0.22" → v0.26;
new "Security hardening (post-/cso pass)" subsection at top of itemized
changes; CLI flag list updated for --public-url.
- src/core/oauth-provider.ts: pgArray escape, validateRedirectUri,
registerClient enforces validation, DELETE...RETURNING in
exchangeAuthorizationCode + exchangeRefreshToken.
- src/commands/serve-http.ts: cookie-parser import + wire-up,
publicUrl option, issuerUrl honors it, startup banner shows issuer.
- src/commands/serve.ts: parses --public-url and threads through.
- src/cli.ts: help text adds --public-url URL flag.
- test/oauth.test.ts: +7 regression tests (now 34 total).
- llms-full.txt: regenerated.
Typecheck clean. 34 oauth + 14 cli tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
74 KiB
TODOS
ci-local-mirror
CI-skip artifact + signature for stages 1+2 follow-up
Priority: P0
What: After a successful local CI run via bun run ci:local, write .ci-cache/passed-<commit-sha>.json containing {commit, test_set_hash, bun_version, schema_hash, signature}. Push to a ci-cache orphan branch (or GH Releases). CI's first step fetches the artifact for the current SHA and skips the test job if (a) signature matches Garry's GPG/SSH key, and (b) test_set_hash matches what CI would have run.
Why: Stages 1+2 (shipped in this branch) give a strong local CI gate, but PR CI still re-runs every test on every push. Stage 3 closes the loop and trades ~10 min of CI wall-time for sub-second artifact verification on Garry's own pushes. External PRs are unaffected because the signature won't match — they hit the normal CI path.
Pros:
- ~10 min/PR saved on Garry's own pushes; the local gate becomes the source of truth.
- External contributor PRs untouched (no security regression).
- Forces a clear test-set-hash contract: any drift in what local-vs-CI run is caught at verification time.
Cons:
- Trust model needs careful design: signature scheme, key rotation, what happens when signature verification fails.
- Cache invalidation is real — if env or service version drifts between local run and CI, a stale local pass could ship to master.
- Adds a
ci-cachebranch / artifact storage surface to maintain.
Context:
- Discussed during the eng-review of the local CI mirror plan at
~/.claude/plans/lets-do-1-2-dockerfile-ci-zany-charm.md. - Don't start until stages 1+2 have been used for ~2 weeks AND the
scripts/e2e-test-map.tshas stabilized (so test_set_hash is a meaningful identity). - Initial trust-but-verify: run both local and CI in parallel for ~1 week before flipping the skip; alert on any disagreement.
Effort: M (human ~2-3 days + ~1 week trust-but-verify period running both local + CI in parallel; CC ~1 day for the mechanics).
Depends on / blocked by: Stages 1+2 (this PR) landing first.
test/e2e/multi-source.test.ts cascade test isn't isolated
Priority: P1
What: The "sources remove cascades to pages + chunks + timeline + links + files" test in test/e2e/multi-source.test.ts:281 fails when the file runs after other E2E files in the sequential bash scripts/run-e2e.sh order, but passes 20/20 on a fresh Postgres volume. The failing assertion is SELECT COUNT(*) FROM links WHERE from_page_id = aliceId expecting 0, getting 1 — so a prior file's setup left a links row that references a page id the cascade test happens to reuse. The test's own setupDB() truncates but doesn't sweep all referencing rows back when ids collide.
Why: Surfaced when bun run ci:local (this PR's local CI gate) ran the full sequential E2E. CI never catches it because .github/workflows/e2e.yml:40 only runs mechanical.test.ts + mcp.test.ts on PRs and nightly Tier 1. So 27 of 29 E2E files including this one aren't actually exercised by CI today. The local gate is stronger and surfaces real cross-file isolation gaps.
Pros:
- Fixing isolation makes
bun run ci:local(full E2E) reliably green. - Same fix likely to harden other E2E files that share id namespaces.
- Lets us turn
bun run ci:localinto a real ship gate.
Cons:
- Could require a per-file "namespace your test ids" pattern, ~30 min per affected file across the suite.
Context:
- Repro:
bash scripts/run-e2e.sh test/e2e/multi-source.test.tsagainst a stale DB after other E2E files have run → fails. Same against a freshdocker compose down -v && up -d postgres→ passes 20/20. - The test inserts a hardcoded
cascadetestsource id andaliceIdpage id; collisions across runs are predictable. - Likely fix: use
mkdtemp-style randomized source/page ids per test, OR have the test do a deeper reset (DELETE FROM all five tables in beforeEach) instead of relying onsetupDB's TRUNCATE behavior.
Effort: S (CC ~30 min for the multi-source.test.ts fix; M if we audit all 29 E2E files for similar id-collision risk).
Depends on / blocked by: Nothing.
scripts/run-e2e.sh:71 echo overflows on large-output failing tests
Priority: P2
What: When an E2E test fails AND prints lots of output (e.g., multi-source.test.ts floods postgres NOTICE objects), scripts/run-e2e.sh:71 does echo "$output" against a multi-megabyte shell variable. The host pipe to docker-compose-run hits EAGAIN and fails with echo: write error: Resource temporarily unavailable. With set -e, the script aborts at that point, skipping the remaining E2E files and the final SUMMARY block.
Why: When the local CI gate finds a real failure (per the multi-source.test.ts entry above), the user wants to see it AND see how the rest of the suite did. Currently the failure shadows the rest.
Pros:
- See all E2E failures from a single run instead of needing to bisect.
- Quick win, ~5 lines.
Cons:
- None worth listing.
Context:
- Reproduced live during plan verification on 2026-04-29. Previous
multi-source.test.tsfailure killed the script before postgres-bootstrap, postgres-jsonb, etc. could run. - Likely fix: replace
echo "$output"withprintf '%s\n' "$output", or write$outputto a tmpfile andcatit (handles large blobs better than echo over pipes), or pipe throughstdbuf -o0. - Don't suppress the postgres NOTICE flood at the test layer — that's separate; here we just want the script to not die when bun's stderr is verbose.
Effort: S (human or CC: ~10 min).
Depends on / blocked by: Nothing.
claw-test E2E (v0.22.16 follow-ups)
Hermes runner — src/core/claw-test/runners/hermes.ts
Priority: P2
What: Add a Hermes implementation of the AgentRunner interface. v1 ships only OpenClaw; v1.1 lands hermes once we have real friction reports from openclaw to validate the contract against.
Why: Cross-agent diff (gbrain friction diff --base openclaw --compare hermes) is the highest-leverage next signal. Friction unique to one agent vs common-to-both separates "agent contract bug" from "gbrain bug" automatically.
Effort: S (CC ~30m). Depends on: v1 openclaw runner producing real friction reports first.
Friction analytics suite — diff / trend / migration-stub
Priority: P2
What: Three new gbrain friction subcommands deferred from v1:
gbrain friction diff --base <run-or-agent> --compare <run-or-agent>(cross-agent comparison; ~80 LOC)gbrain friction trend [--since <version-or-date>] [--phase <name>](time-series across runs; ~60 LOC)gbrain friction migration-stub [--threshold N](clusters friction by phase + tokens, emitsskills/migrations/v[N+1].mdstub; ~150 LOC)
Why: Turns point-in-time reports into a slope. Pairs with the v1.1 public scoreboard.
Effort: M (CC ~2h total).
Scenario expansion — supabase-migration and supervisor-restart
Priority: P2
What: Two more scenarios under test/fixtures/claw-test-scenarios/:
supabase-migration—gbrain init --pglitethengbrain migrate --to supabase; verifies the cross-engine migration pathsupervisor-restart— kill worker mid-job; verify supervisor recovers without data loss
Why: These are the other highest-historical-pain regression points (per CLAUDE.md fix-wave history). v1 ships only fresh-install + upgrade-from-v0.18 because Codex flagged that mixing them dilutes the fresh-install signal; v1.1 lands them as separate scenarios.
Effort: M (CC ~1h each).
Real v0.18 SQL dump for upgrade scenario
Priority: P2
What: The upgrade-from-v0.18 scenario ships scaffolded — seed/dump.sql is missing. The harness gracefully no-ops the seed phase when absent, so the scenario currently behaves like fresh-install. v1.1: generate a real v0.18-shape PGLite dump per the procedure documented in test/fixtures/claw-test-scenarios/upgrade-from-v0.18/seed/README.md.
Why: Without a real seed, the scenario doesn't actually exercise the migration chain forward-walk. That's the whole point of the upgrade scenario — proves issue #239/#243/#266/#357 class regressions stay fixed.
Effort: S (CC ~30m once a v0.18 checkout is handy). Depends on: ability to run a v0.18 gbrain build.
Public scoreboard — gbrain-evals.io/friction
Priority: P3
What: Sibling-repo PR in garrytan/gbrain-evals that renders friction JSONL into a public dashboard. Friction count per version per agent, line charts over time. v1's JSONL already includes gbrain_version + agent tags so the scoreboard is a thin layer on top.
Why: Marketing surface. Proves install quality is improving release-over-release. The friction loop becomes visible to the world, not just maintainers.
Effort: M. Depends on: a working live mode and ≥10 real friction reports.
PTY-mode transcript capture
Priority: P3
What: transcript-capture.ts currently uses plain child_process.spawn pipes. Some agents only emit ANSI colors / progress UI on a TTY. v1.1 adds a PTY mode (likely via node-pty) so live-mode transcripts capture the full agent UX.
Why: Faithful transcripts make the friction → reasoning link more useful. v1 accepts that some agent UI is lost.
Effort: S (CC ~30m). Mostly a ~30 LOC swap inside spawnWithCapture.
Read-side host-isolation ($GBRAIN_HOST_HOME)
Priority: P3
What: v0.22.16 confined every ~/.gbrain write site to honor $GBRAIN_HOME. But src/commands/init.ts:299-313 still reads real ~/.claude / ~/.openclaw / ~/.codex / ~/.factory / ~/.kiro for module fingerprinting (host detection). Even with write-isolation, a claw-test running on a developer's box discovers their real installed mods. v1.1: add a separate $GBRAIN_HOST_HOME override for the read-side detection so the claw-test can run truly hermetic.
Why: v1's hermeticity contract is "writes are isolated, reads are not." v1.1 closes the read-side gap.
Effort: S (CC ~30m).
Routing-callout sweep — annotate skills the claw-test exercises
Priority: P3
What: skills/_friction-protocol.md is a cross-cutting convention. v1.1: sweep the 4–6 skills the claw-test actually exercises (setup, brain-ops, query, ingest, smoke-test, the migrations the test covers) and add a > **Convention:** see [skills/_friction-protocol.md](_friction-protocol.md). callout via the existing src/core/dry-fix.ts shape so DRY auto-fix doesn't fight it.
Why: Right now agents only call gbrain friction log if they find the protocol skill on their own. The callouts route them there proactively from any harness-exercised skill.
Effort: S (CC ~15m).
minions / worker (v0.22.14 follow-ups)
v0.22.15 — Embed cooperative-abort (HIGHEST PRIORITY — daily pain)
Priority: P0
What: Plumb signal: AbortSignal through runPhaseEmbed →
src/commands/embed.ts → embedBatch in src/core/embedding.ts. Check
signal?.aborted between OpenAI batch calls (every ~100 texts, ~2s
real-time) and between slugs in the per-slug loop.
Why: Embed phase ignores signal.aborted between batches today. Job
wall-clock timeout fires → handler keeps running → cycle's finally block
unreachable → gbrain_cycle_locks row stays held indefinitely. Every
subsequent autopilot cron cycle sees cycle_already_running → skips. Lock
TTL is 30 min; new cycles give up before that. Doctor reports UNHEALTHY.
The chain in production: ~5min cron submits cycle → 22K stale pages → embed phase takes 10–15 min → 600s timeout fires → job dead-lettered → embed keeps running → lock held → all subsequent cycles skip. Garry hits this DAILY on his production brain.
Pros: Closes the daily wedge. Makes timeouts actually effective. Lets operators bump worker timeouts confidently knowing abort actually stops work.
Cons: Touching the embed hot path; small risk of botching the abort checks. Mitigation: between-batch granularity (~2s), not per-text (too fine) or per-slug (too coarse for 500+ chunk slugs).
Context: PR #503 (v0.22.14) catches the SYMPTOM (worker stalled, queue piling up) via self-health-monitoring. This PR catches the CAUSE for one specific failure class. Both fixes are needed; they're complementary, not duplicative.
Files to touch:
src/core/cycle.ts:579—runPhaseEmbed(engine, dryRun)→ addsignal?: AbortSignalargsrc/core/cycle.ts:803— passopts.signalthroughsrc/commands/embed.ts:~363— accept signal, check between slugssrc/core/embedding.ts:51-56—embedBatch(texts, onProgress?, signal?), check between for-loop iterations ofBATCH_SIZEslices
Tests required:
- embedBatch checks signal between OpenAI calls; aborts within one batch (~2s)
- Per-slug loop in
embed.tschecks signal between slugs - End-to-end: cycle handler with embed phase + signal aborted mid-flight →
finally runs →
gbrain_cycle_locksrow deleted - Regression: 1K+ chunks scenario — embed does NOT block lock release when timeout fires
Effort: M (human: ~3 hr / CC: ~30 min).
Depends on / blocked by: Nothing. v0.22.14 ships first.
v0.23+ — Bare-worker engine reconnect parity with supervisor
Priority: P2
What: Extract the supervisor's reconnect-then-fail pattern into
MinionWorker so bare workers can retry transient DB blips before exiting.
Today the supervisor calls engine.reconnect() after 3 consecutive DB health
failures (#406); the bare worker just emits 'unhealthy' and the CLI calls
process.exit(1).
Why: Bare-worker behavior is more disruptive than supervised behavior on transient PgBouncer blips. A bare worker restarts the entire process; a supervised worker just reconnects the pool. Operationally the supervisor approach is gentler (no in-flight job loss, no PM restart latency).
Pros: Unifies bare and supervised behavior. Reduces process churn on transient network blips.
Cons: More code in MinionWorker; risk of reconnect masking a real
problem. Mitigation: cap retry attempts, fall through to 'unhealthy'
emission after the cap.
Context: Filed during v0.22.14 plan-eng-review. The asymmetry is documented in v0.22.14 CHANGELOG as deliberate; this TODO captures the "unify someday" intent.
Effort: S (human: ~2 hr / CC: ~20 min).
Depends on / blocked by: Nothing.
v0.23+ — minion_workers heartbeat table for queue_health doctor (B7)
Priority: P3
What: Add a minion_workers table (worker_id PK, hostname,
last_heartbeat, queue, concurrency, started_at) so the existing
queue_health doctor check (Postgres path) can detect dead workers via
heartbeat staleness instead of relying on the indirect lock_until proxy.
Why: v0.19.1 added queue_health checks for stalled-active jobs and
waiting-depth threshold. The worker-heartbeat subcheck was deferred (B7)
because the lock_until-on-active-jobs proxy can't distinguish "worker
exited cleanly" from "worker idle" — a check that cries wolf erodes trust
in every doctor check. With a real heartbeat row, doctor can say "no worker
seen in N intervals" with confidence.
Pros: Doctor's queue_health becomes ground-truth. Detects "worker
container died but cron didn't restart it" scenario.
Cons: New table, schema migration, every health-tick UPSERTs. Costs a write per worker per minute (default).
Context: Filed during v0.22.14 plan-eng-review. PR #503's self-health monitoring is the worker-side liveness; this would be the queue-side ground-truth.
Effort: M (human: ~1 day / CC: ~1 hr).
Depends on / blocked by: Schema migration system; nothing else.
sync (v0.22.13 follow-up — PR #490 review)
D-PR490-1 — Plumb resolved database_url through SyncOpts
Priority: P3
What: Add database_url?: string (or a richer resolvedConnection shape) to
SyncOpts and have the caller (runSync, the cycle handler, the jobs handler)
populate it from the active engine instead of having performSync /
performFullSync / import.ts each call loadConfig() separately. Today every
sync run hits the config file three times.
Why: v0.18 multi-source brains can in principle run different sources against
different database_url endpoints (or different per-source overrides via
sources.config_jsonb). Right now loadConfig() returns the global config, and
that always matches the engine in practice — but the convention papers over a
real divergence the moment someone wants per-source connection settings. Folding
the resolution into SyncOpts makes the worker-engine creation in sync.ts and
import.ts deterministic from SyncOpts alone.
Pros:
- Removes 3 redundant
loadConfig()calls per sync. - Makes
performSync/performFullSyncside-effect-free with respect to the on-disk config file. - Sets up for per-source
database_urloverrides without further refactor. - Makes the v0.22.13 belt-and-suspenders fallback (PR #490 Q3) cleaner — no
more
!config?.database_urlshort-circuit inside the parallel branch.
Cons:
- API-shape change to
SyncOpts(mild; not externally exported). - Touching three callers (
runSync, jobs handler,cycle.tsrunPhaseSync). - Only worth doing when paired with a per-source override story; otherwise it's just plumbing.
Context: Surfaced during the PR #490 plan-eng-review (parallel sync). Deferred because it isn't on the v0.22.13 critical path. The same pattern would benefit the cycle handler and the autopilot daemon. See the plan-eng-review decisions log: A4 = "Defer; file as TODO."
Depends on / blocked by: Nothing structural. Best paired with the v0.18
per-source config_jsonb work if/when that lands.
sync error-code classification (PR #501 follow-ups)
Plumb structured ParseValidationCode through ImportResult
Priority: P2
What: Replace the regex-on-error-message path in src/core/sync.ts:classifyErrorCode
with a structured code field threaded through ImportResult from the parse layer.
Three changes:
src/core/import-file.ts:362— callparseMarkdown(content, relativePath, { validate: true, expectedSlug })soparsed.errors[0].codeis populated.src/core/import-file.ts— addcode?: stringtoImportResult. Promote the structured code (or'SLUG_MISMATCH'when the existing expectedSlug check trips) into the result envelope alongsideerror.src/commands/sync.ts:488— extendfailedFilesshape withcode?: string.recordSyncFailuresalready accepts the field; the only thing missing is the capture site populating it.src/core/sync.ts:classifyErrorCode— keep as a fallback for un-coded errors (DB exceptions, generic catches). Primary path reads the structured code.
Why: The repo already has ParseValidationCode + ParseValidationError in
src/core/markdown.ts:5-18, and three other consumers (src/commands/lint.ts:72,
src/commands/frontmatter.ts:148, src/core/brain-writer.ts:314) read structured
errors directly. Sync is the outlier — it calls parseMarkdown without validation
and reverse-engineers codes via regex. PR #501 shipped that regex out of pragmatism;
this TODO removes ~50% of classifyErrorCode and eliminates a class of false-positives.
Pros:
- One source of truth for parse codes (the enum in
markdown.ts). - Eliminates regex fragility — adding a new validation code in
markdown.tsautomatically flows to sync without a new regex. - Closes the case where canonical messages (
File is empty...,No closing ---...) don't match aspirational regex patterns.
Cons: Touches ImportResult interface, which ripples through src/commands/import.ts:105,
src/commands/sync.ts:498-510, src/core/cycle.ts, brain-writer reconciler.
Context: PR #501 documented this as P3 in the eng review at
~/.claude/plans/then-codex-synchronous-toucan.md. Codex's outside-voice review
agreed independently. The fix is small — ~50 lines including tests + downstream
call sites — and it's the correct architectural endpoint.
Effort: M (human: ~2 hr / CC: ~20 min).
Depends on / blocked by: Nothing.
CHANGELOG migration note for acknowledgeSyncFailures() shape change
Priority: P0 — required at /ship time
What: When PR #501 ships, the release CHANGELOG entry MUST include this
### For contributors block:
### For contributors
`acknowledgeSyncFailures()` now returns `{count, summary}` instead of `number`.
If you import this directly from `gbrain/sync`, replace `n` with `result.count`
and use `result.summary` for the new code-grouped breakdown.
Why: The function is exported from src/core/sync.ts:433 and reachable via
the package exports map. External TS consumers (gbrain-evals, host agent forks)
that imported it got number and now get an object — silent type break.
Effort: XS (human: ~1 min). Just don't forget.
Depends on / blocked by: PR #501 ship.
Concurrent-safe ack of ~/.gbrain/sync-failures.jsonl
Priority: P3
What: Two concurrent gbrain sync runs hitting acknowledgeSyncFailures()
can clobber each other. The function does a whole-file writeFileSync rewrite
(src/core/sync.ts:433-455); recordSyncFailures() does independent
appendFileSync (src/core/sync.ts:395-416). Concurrent ack + append can lose rows.
Why: Pre-existing — predates PR #501. Real risk only on autopilot setups where multiple sync invocations might overlap (rare today, more likely as multi-source sync matures).
Fix sketch: Atomic rename pattern (write to sync-failures.jsonl.tmp, then
renameSync) plus a file lock for the read-modify-write cycle. Or move the
acknowledged-set to the DB.
Effort: S (human: ~1 hr / CC: ~10 min).
Depends on / blocked by: Nothing.
test-infra
Parallel-load timeout flake on v0.21 PGLite-heavy tests
Priority: P0
What: 22 tests added in v0.21.0 (Code Cathedral II) consistently fail in the full bun test run with timeout-pattern elapsed times of 7-10s, but pass in isolation. Every failing test calls engine.initSchema() in beforeAll without a timeout extension. Under parallel load (168 test files now run concurrently after v0.21 added ~24 new files), initSchema exceeds bun's default 5s beforeAll timeout.
Affected files include (non-exhaustive): test/sync-strategy.test.ts, test/cathedral-ii-brainbench.test.ts, test/code-edges.test.ts, test/reindex-code.test.ts, test/reconcile-links.test.ts, test/two-pass.test.ts, test/parent-symbol-path.test.ts, test/pglite-v0_19.test.ts.
Why: Currently triaged as "skip pre-existing, ship anyway" but that's not a real fix. Blocks /ship for anyone whose CHANGELOG-time test run sees them.
Pros: Fixing it lets /ship run cleanly without manual triage every release.
Cons: ~22 file edits adding beforeAll(async () => {...}, 30000) is mechanical but dull.
Context: Same pattern fixed in v0.20.5 wave for test/e2e/minions-shell-pglite.test.ts. Single-file repro: each fails in bun test, passes in bun test <file>. Reproduces with my changes stashed, so it's on master.
Effort: S (human: ~30 min / CC: ~5 min). Mechanical: grep for beforeAll(async () => { in affected files, add , 30000) argument.
Depends on / blocked by: Nothing.
resolver / check-resolvable (v0.22.4 follow-ups)
D10 — Extend check-resolvable to parse RESOLVER.md disambiguation rules
Priority: P2
What: Extend src/core/check-resolvable.ts:357-390 to parse a structured
disambiguation block in RESOLVER.md (e.g. a ## Disambiguation rules
numbered list with parseable <trigger> → <winning-skill> shape) and treat
resolved overlaps as non-issues. Then the action message at
src/core/check-resolvable.ts:388 ("Add disambiguation rule in RESOLVER.md OR
narrow triggers") stops lying about the OR — currently only the second branch
silences the warning.
Why: The current MECE-overlap fix path forces authors to delete user-facing
triggers from skill frontmatter. That's wrong for cases where two skills
legitimately respond to the same phrase under different contexts (e.g.
"citation audit" → focused fix vs broader brain health). A real
disambiguation parser would let RESOLVER.md carry the resolution while
keeping both skills' triggers intact for chaining.
Pros:
- The action message stops misleading users.
- v0.22.4 D2 used the "narrow triggers" path because the disambiguation parser doesn't exist yet; landing this would let v0.23+ keep dual triggers for genuinely-overlapping skills.
- Aligns RESOLVER.md's stated role (the dispatcher) with what the checker actually reads.
Cons:
- Introduces a new
RESOLVER.mdsyntactic contract that other tooling now has to respect (parser, lint, downstream forks reading the same file). - Risk of false-positive resolution if the parser is loose.
- ~80 lines of parser + tests; not blocking anything in v0.22.4.
Context:
- The "OR" in the action message is misleading today. Confirmed at
src/core/check-resolvable.ts:388. - The MECE detector loop is at
src/core/check-resolvable.ts:357-390. - The disambiguation rules already exist as prose in
skills/RESOLVER.md(the citation-audit row added in v0.22.4 is the pattern). They're agent-facing routing hints today, not parsed structure.
Effort: S (human: ~4-6 hours / CC: ~30 min for parser + 12-16 test cases).
Depends on / blocked by: Nothing.
code-indexing (v0.21.0 Cathedral II follow-ups)
B2 — Magika auto-detect for extension-less files (Layer 9 deferred)
Priority: P2
What: Embed Google's Magika ML classifier (~1MB ONNX) as a bundled asset. Wire into detectCodeLanguage as the fallback for files with no recognized extension (Dockerfile, Makefile, .envrc, shell scripts with shebangs but no .sh). The chunker already has setLanguageFallback(fn) as a module-level hook.
Why: v0.20.0 widens the file classifier from 9 to 35 extensions (Layer 2), covering most real-world cases. Extension-less files still slip through to recursive chunks. Magika would close the last common case.
Pros: Completes the file-classification story. Unblocks chunker on real-world configs + build scripts.
Cons: ~1MB asset bundled with bun --compile. Integration risk: Magika's ONNX runtime needs WASM compat with bun. The plan explicitly allowed deferring B2 because bundling surprises late in implementation are costly.
Context:
src/core/chunkers/code.tsexportssetLanguageFallback(fn: LanguageFallback | null)— call at process start with a Magika-powered classifier.detectCodeLanguage(filePath, content?)already accepts optional content for fallback paths.- The NPM
magikapackage is the first thing to try; needs bun-compile compatibility verification.
Effort: M (human: ~2-3 days / CC: ~2 hours for the integration + CI guard).
Depends on / blocked by: Nothing. Hook is in place as of v0.20.0.
A4 — full doc_comment extraction at chunk time
Priority: P2
What: When the chunker emits a method/class/function, look at the comment node(s) immediately preceding the declaration and persist them as content_chunks.doc_comment. The FTS trigger from Layer 1b already weights doc_comment 'A' above chunk_text 'B' — the ranking is ready, the column is populated NULL today.
Why: "how does X handle N+1" should rank the docstring that explains N+1 above the function body or any prose paragraph. Layer 1b paved the ranking half; extraction is the remaining half.
Pros: Material MRR lift on natural-language queries. Zero schema work (column + trigger already in place).
Cons: Per-language convention detection — JSDoc blocks, Python docstrings (first string expression in a function body), C-style doc comments, etc. Not hard but each language has edge cases.
Context:
src/core/chunkers/code.tsemits chunks inchunkCodeTextFull. Walk each declaration's preceding sibling(s) for comment nodes.- ChunkInput already has
doc_comment?: string. Populate at chunk time and it flows throughupsertChunks(Layer 6 wired those columns). - Per-language config: leading-comment type names per language (
comment,line_comment,block_comment,documentation_comment). - Test hook:
test/cathedral-ii-brainbench.test.tshas adoc_comment_matchingplaceholder — flesh it out end-to-end.
Effort: M (human: ~2 days / CC: ~90 min for the 8 Layer-5 langs).
Depends on / blocked by: Nothing. Layer 1b + Layer 6 both in place.
C6 — gbrain code-signature "(A, B) => C"
Priority: P3 (stretch)
What: Type-signature retrieval via tree-sitter type captures per language. "Find every function whose signature returns a Promise" or "(string, number) => boolean".
Why: Each language's type system is its own mini-cathedral. Ship per-language rather than as one item.
Effort: L per language (typescript-first).
Depends on / blocked by: Nothing — additive on the Layer 5 edge schema.
Cross-file edge resolution (Layer 5 precision upgrade)
Priority: P3
What: Today every call edge lands unresolved in code_edges_symbol with to_symbol_qualified = bare callee name. Second-pass resolution: after all code files import, walk every code_edges_symbol row and try to resolve to_symbol_qualified via symbol_name_qualified join; if found within the same source, write a resolved row to code_edges_chunk.
Why: getCallersOf("searchKeyword") currently returns the Layer 6 ambiguity — every searchKeyword call site in any class. Receiver-type analysis lifts this.
Effort: L. Needs receiver-type inference; can ship per-language.
Depends on / blocked by: Nothing — UNION-on-read path keeps unresolved edges surfaced even without this.
P3 — Dev experience: test suite parallelism on fast multi-core machines
Context: bun test on M-series Macs spawns ~1 worker per core. test/dream.test.ts (5 describe blocks, 11 tests) and test/orphans.test.ts create a fresh PGLite engine in beforeEach that runs ~20 schema migrations per test. Under parallel load, WASM-instance contention causes ~18 beforeEach timeouts at 5–9s.
Evidence: CI (ubuntu-latest, fewer cores) is green on every PR. Running the suspect files in isolation (bun test test/dream.test.ts test/orphans.test.ts) is also green. Reproduces only on fast multi-core local machines running the full 136-file parallel suite.
Fix: move engine creation from beforeEach to beforeAll per describe block; add a data-reset helper (delete-all-rows-in-relevant-tables) between tests. ~80 LOC change across two test files.
Priority: P3 because production CI is unaffected. Hits local dev iteration speed on fast Macs.
Found: 2026-04-24 during v0.19.0 production-readiness review.
Completed
Checks 5 + 6 for check-resolvable
Completed: v0.19.0 (2026-04-22)
Both checks shipped as real implementations, not just filed issues:
- Check 5 (trigger routing eval):
src/core/routing-eval.ts+gbrain routing-evalCLI. Structural layer runs incheck-resolvableby default;--llmopts into LLM tie-break. Fixtures live atskills/<name>/routing-eval.jsonl. - Check 6 (brain filing):
src/core/filing-audit.ts+skills/_brain-filing-rules.json. Newwrites_pages:+writes_to:frontmatter. Warning-only in v0.19, error in v0.20.
DEFERRED[] in src/commands/check-resolvable.ts is now empty — v0.19 shipped both deferred checks as working code paths, not as issue URLs. The export stays in place for future deferred checks.
BrainBench Cats 5/6/8/9/11 — shipped to sibling repo
Completed: v0.20.0 (2026-04-23)
All five previously-deferred BrainBench categories shipped as working runners in the sibling repo github.com/garrytan/gbrain-evals:
- Cat 5 Provenance —
eval/runner/cat5-provenance.tswith dedicatedclassify_claimtool (3-way label:supported | unsupported | over-generalized) - Cat 6 Prose-scale auto-link precision —
eval/runner/cat6-prose-scale.ts(baseline-only) +eval/runner/adversarial-injections.ts(6 injection kinds) - Cat 8 Skill Compliance —
eval/runner/cat8-skill-compliance.ts(brain-first / back-link / citation-format / tier-escalation, deterministic from tool-bridge trace) - Cat 9 End-to-End Workflows —
eval/runner/cat9-workflows.ts(rubric-graded) - Cat 11 Multi-modal Ingestion —
eval/runner/cat11-multimodal.ts(PDF/audio/HTML)
Plus supporting infrastructure: agent adapter (Sonnet + 12 read + 3 dry_run tools), structured-evidence Haiku judge contract, PublicPage/PublicQuery sealed qrels, 6-artifact flight-recorder, 6 portable JSON schemas for v1→v2 driver swap.
Scope pivot: originally planned for in-tree v1.1 delta; mid-PR pivoted to extract the entire eval harness so gbrain users don't download the ~5MB corpus at install time. BrainBench is now a public sibling benchmark; gbrain ships clean.
v0.10.5: inferLinkType residuals (works_at, advises)
Completed: v0.20.0 (2026-04-23)
src/core/link-extraction.ts — WORKS_AT_RE and ADVISES_RE expanded with
rank-prefixed engineer patterns ("senior/staff/principal/lead engineer at"),
discipline-prefixed ("backend/frontend/ML/security engineer at"), broader role
verbs ("manages engineering at", "running product at", "heads up X at"),
possessive time ("his/her/their time at"), role-noun forms ("tenure as",
"stint as", "role at"), advisory capacity phrasings, "as an advisor" forms,
and qualifier-specific advisors. New EMPLOYEE_ROLE_RE prior fires for
self-identified employees at the page level, biasing outbound company refs
toward works_at when per-edge verbs are absent. Precedence: investor > advisor
employee. Existing tests in
test/link-extraction.test.tscover the new patterns.
P1 (BrainBench v1.1 — remaining categories)
Cats 5/6/8/9/11 shipped to the sibling repo in v0.20.0 — see the Completed section above. One remaining scope item:
BrainBench Cat 1+2 at full scale
What: Existing benchmark-search-quality.ts (29 pages, 20 queries) and benchmark-graph-quality.ts (80 pages, 5 queries) currently pass at small scale. v1.1 extends both to 2-3K rich-prose pages generated via Opus to surface scale-dependent failures (tied keyword clusters, hub-node fan-out, prose-noise extraction precision).
Why deferred from PR #188: Needs ~$200-300 of Opus tokens for the rich corpus. The 80-page version already proves algorithmic correctness; scale-up proves it survives real-world load.
Threshold: maintain v1 metrics at 30x scale.
v0.10.4: inferLinkType prose precision fix
Shipped in PR #188. BrainBench Cat 2 rich-corpus type accuracy went from 70.7% → 88.5%. Fix: widened verb regexes (added "led the seed/Series A", "early investor", "invests in", "portfolio company", etc.), tightened ADVISES_RE to require explicit advisor rooting (generic "board member" matches investors too), widened context window 80→240 chars, added person-page role prior (partner-bio language → invested_in for outbound company refs only). Per-type after fix: invested_in 91.7% (was 0%), mentions 100%, attended 100%. works_at 58% and advises 41% are next iteration's residuals.
v0.10.4: gbrain alias resolution feature (driven by Cat 3)
What: Add an alias table to gbrain so "Sarah Chen" / "S. Chen" / "@schen" / "sarah.chen@example.com" resolve to one canonical entity. Schema: aliases (id, slug, alias_text) with a unique index. Search blends alias matches into hybrid scoring.
Why: BrainBench Cat 3 measured 31% recall on undocumented aliases — that's the v0.10.x baseline. With alias table, should jump to 80%+.
Depends on: Cat 3 baseline (shipped in PR #188).
P1
Minions shell jobs — Phase 2 scheduling (deferred from v0.13.0)
What: minion_schedules table + autopilot-cycle scanner that submits due shell jobs.
Why: v0.13.0 moves shell scripts to Minions but still leaves scheduling in the host crontab. Your OpenClaw's scripts/service-manager.sh + crontab is the only piece left on the host side. A DB-driven scheduler would mean a single gbrain autopilot --install replaces the host crontab entirely, scheduling is visible via gbrain jobs list --scheduled, and downtime-on-one-machine tolerance improves (schedule is shared DB state, not per-host crontab).
Pros: Canonical host-agnostic deployment. No more host-specific crontab.
Cons: Cross-engine migration complexity (new table on both PGLite + Postgres). Autopilot-cycle scanner needs to handle missed-schedule semantics (fire-once-on-startup or skip-if-past-now), and this is where every other cron-like system has historically accrued bugs.
Depends on: v0.13.0 shell jobs shipped. ✅
gbrain crontab-to-minions <file> migration helper (deferred from v0.13.0)
What: Parse an existing crontab file, emit a proposed rewrite using gbrain jobs submit shell ... for each deterministic entry, keep LLM-requiring entries as-is.
Why: Hand-rewriting ~14 OpenClaw cron entries is error-prone and one-shot. A helper would make the migration reversible and auditable (diff the before/after crontab, dry-run the first N, commit).
Pros: Removes the "rewrite 14 lines by hand" tax every agent operator pays on adoption.
Cons: Crontab parsing is historically fiddly (5-field vs 6-field, @hourly aliases, Vixie extensions, env vars in crontab). Could misrewrite entries with shell substitution.
Depends on: v0.13.0 shell jobs shipped. ✅
Batch the DB-source extract read path (deferred from v0.12.1)
What: extractLinksFromDB and extractTimelineFromDB at src/commands/extract.ts:447, 504 issue one engine.getPage(slug) per slug after engine.getAllSlugs(). On a 47K-page brain that's still 47K serial reads over the Supabase pooler.
Why: v0.12.1 fixed the write-side N+1 with batched INSERTs (~100x fewer round-trips). The read side still does serial getPage() calls — each fetches compiled_truth + timeline + frontmatter (tens of KB per page). On a 47K-page Supabase brain that's ~10-20 minutes of read latency before any work happens. The v0.12.0 orchestrator's backfill uses --source db, so this stays slow until fixed.
Pros: Mirrors the write-side fix on the read path. Combined with batched writes, full re-extract on a 47K-page brain should drop from "minutes" to "seconds" end-to-end. Eliminates the implicit listPages-pagination-mutation learning risk by giving you a snapshot read.
Cons: New engine method (getPagesBatch(slugs: string[]) → Promise<Page[]> or a streaming cursor) needs to land on both PGLite and Postgres. Memory budget — a 47K-page brain with ~30KB/page is ~1.4GB if loaded all at once; needs chunked iteration (e.g., 500 slugs/query, stream-process).
Context: Codex's plan-time review and the testing/performance specialists at ship time both flagged this. Filed during v0.12.1 to ship the bug fix without scope creep. Approach: add getPagesBatch(slugs) returning chunked results, then update the 4 DB-source extract paths to consume it.
Depends on: v0.12.1 ships first.
Batch embedding queue across files
What: Shared embedding queue that collects chunks from all parallel import workers and flushes to OpenAI in batches of 100, instead of each worker batching independently.
Why: With 4 workers importing files that average 5 chunks each, you get 4 concurrent OpenAI API calls with small batches (5-10 chunks). A shared queue would batch 100 chunks across workers into one API call, cutting embedding cost and latency roughly in half.
Pros: Fewer API calls (500 chunks = 5 calls instead of ~100), lower cost, faster embedding.
Cons: Adds coordination complexity: backpressure when queue is full, error attribution back to source file, worker pausing. Medium implementation effort.
Context: Deferred during eng review because per-worker embedding is simpler and the parallel workers themselves are the bigger speed win (network round-trips). Revisit after profiling real import workloads to confirm embedding is actually the bottleneck. If most imports use --no-embed, this matters less.
Implementation sketch: src/core/embedding-queue.ts with a Promise-based semaphore. Workers await queue.submit(chunks) which resolves when the queue has room. Queue flushes to OpenAI in batches of 100 with max 2-3 concurrent API calls. Track source file per chunk for error propagation.
Depends on: Part 5 (parallel import with per-worker engines) -- already shipped.
P0
PGLite test-runner concurrency flake (~27 false failures in full bun test)
What: Fix the concurrent-PGLite-init flake that surfaces ~27 error: PGLite not connected. Call connect() first. failures when bun test runs all 174 unit-test files together. Each failing file passes in isolation; failures only appear under full-suite parallelism.
Why: The failures are masking real signal. /ship and any solo dev running bun test has to manually triage 27 results every time. Today they're all in test/cathedral-ii-pglite.test.ts, test/cathedral-ii-brainbench.test.ts (Layer 5/6/7/8 + parent_scope_coverage + call_graph_recall), test/sync.test.ts (4 dry-run cases), test/reindex-code.test.ts (Layer 13 E2). All exist on master and date back to v0.12.3-v0.21.0 — pre-existing, not caused by any one branch.
Context: Confirmed pre-existing on master via git diff origin/master...HEAD --stat -- <failing files> returning empty. Tests pass cleanly in 1-3-file batches. Wall clock for the full suite is 596s. Likely root causes: (a) PGLite has a singleton or shared OPFS-like state that races under parallel PGlite.create() calls, (b) test/cathedral-ii-pglite.test.ts "fresh-install schema" tests assume exclusive PGLite access, (c) bun test concurrency exceeds what PGLite's WASM init can handle.
Pros: Green suite signal. Faster shipping. Stops eroding trust in bun test.
Cons: Likely needs PGLite engine-per-test isolation (each test gets its own dedicated engine instance via tmpdir) or a bun test --concurrency=N cap. Both touch test infra used by 50+ files.
Effort: M (human: 1 day to root-cause + implement / CC: ~2-3 hours via /investigate).
Discovered: v0.25.0 ship, 2026-04-25.
Fix bun build --compile WASM embedding for PGLite
What: Submit PR to oven-sh/bun fixing WASM file embedding in bun build --compile (issue oven-sh/bun#15032).
Why: PGLite's WASM files (~3MB) can't be embedded in the compiled binary. Users who install via bun install -g gbrain are fine (WASM resolves from node_modules), but the compiled binary can't use PGLite. Jarred Sumner (Bun founder, YC W22) would likely be receptive.
Pros: Single-binary distribution includes PGLite. No sidecar files needed.
Cons: Requires understanding Bun's bundler internals. May be a large PR.
Context: Issue has been open since Nov 2024. The root cause is that bun build --compile generates virtual filesystem paths (/$bunfs/root/...) that PGLite can't resolve. Multiple users have reported this. A fix would benefit any WASM-dependent package, not just PGLite.
Depends on: PGLite engine shipping (to have a real use case for the PR).
Runtime MCP access control
What: Add sender identity checking to MCP operations. Brain ops return filtered data based on access tier (Full/Work/Family/None).
Why: ACCESS_POLICY.md is prompt-layer enforcement (agent reads policy before responding). A direct MCP caller can bypass it. Runtime enforcement in the MCP server is the real security boundary for multi-user and remote deployments.
Pros: Real security boundary. ACCESS_POLICY.md becomes enforceable, not advisory.
Cons: Requires adding sender_id or access_tier to OperationContext. Each mutating operation needs a permission check. Medium implementation effort.
Context: From CEO review + Codex outside voice (2026-04-13). Prompt-layer access control works in practice (same model as Garry's OpenClaw) but is not sufficient for remote MCP where direct tool calls bypass the agent's prompt.
Depends on: v0.10.0 GStackBrain skill layer (shipped).
P1 (new from v0.25.0 — eval-capture adversarial review)
v0.25.0 eval-capture follow-ups (6 surgical hardenings)
Priority: P1
What: Six targeted hardenings on the v0.25.0 eval-capture surface, all surfaced by the /ship adversarial review and triaged out of the v0.25.0 PR to keep scope tight:
gbrain eval prune --dry-run: replace thelistEvalCandidates(limit:100k) + filtercount with a realengine.countEvalCandidatesBefore(date)method. Today the warning ateval-prune.ts:107-109honestly tells the user the count may be undercounted, but a brain with > 100k rows + old data could still confuse a careful operator. NewBrainEnginemethod on both engines, ~30 LOC, lifts the floor count to a true count.- PII scrubber CC false-positive rate: 16-digit Luhn-valid order IDs / invoice numbers get redacted as
[REDACTED]. Either require a contextual prefix (card,cc,credit) within N chars, or document the tradeoff explicitly indocs/eval-capture.md. The two approaches differ in coverage so list them as alternatives. eval_capture_failures.reasonenum:'scrubber_exception'is dead telemetry — no realistic path emits it (the scrubber is regex-only and never throws). Either remove the value from the schema CHECK + enum, OR wrapscrubPiiin a try-catch insidebuildEvalCandidateInputso the value is actually reachable.id DESCtiebreaker docs: CLAUDE.md says "stable id-desc tiebreaker so--sincewindows never dupe/miss rows". This is true within a single call but doesn't prevent dupe/miss across overlapping windows when LIMIT < total. Either add a realid-cursor (WHERE id < $cursor) for export, or scope the doc claim to "within a single export call".- Public-exports canaries: 6 of 17 subpaths (
gbrainroot,/minions,/engine-factory,/transcription,/backoff,/extract) havecanary: []— the test only checks the import resolves, so a barrel module accidentally losing its named exports would still pass. Pin one stable canary symbol per subpath. EXPECTED_COUNTduplication:scripts/check-exports-count.shandtest/public-exports.test.tsboth hardcode17. Drift risk. Make one read the other (or both compute frompackage.json).
Why: All 6 are real (some informational, some footgun-class) but each is small and surgical. Bundling into one v0.25.1 follow-up PR keeps the v0.25.0 ship clean and lets the fixes land with their own dedicated tests + CHANGELOG entry.
Effort: S total (human: ~half day / CC: ~1.5 hours).
Discovered: v0.25.0 ship adversarial review, 2026-04-25.
P1 (new from v0.7.0)
Constrained health_check DSL for third-party recipes
Completed: v0.9.3 (2026-04-12). Typed DSL with 4 check types (http, env_exists, command, any_of). All 7 first-party recipes migrated. String health checks accepted with deprecation warning + metachar validation for non-embedded recipes.
P1 (new from v0.18.0 — test flakiness)
beforeAll hook timeouts under parallel test runner
What: 17 tests across 9 files (dream, orphans, brain-allowlist, extract-db, multi-source-integration, core/cycle, migrations-v0_12_2, migrations-v0_13_1, oauth) fail with beforeEach/afterEach hook timed out for this test at the 7-10 second threshold when run via bun run test (parallel). Every test passes in isolation (bun test path/to/file.test.ts → 0 fail). Root cause is PGLite schema init racing under concurrent test files.
Why: bun run test is the pre-ship gate and reports these as failures, forcing manual triage on every /ship. The tests themselves are correct — the runner is stressing PGLite boot. Bumping the hook timeout or running E2E-like tests with --bail or serial execution would clear the 18 false positives.
Fix options:
- Bump per-test hook timeout to 30s in
bunfig.toml(quick fix, low risk) - Move PGLite-init-heavy tests to
test/e2e/so they run serially viascripts/run-e2e.sh(follows existing pattern) - Share a module-scoped PGLite instance across describe blocks within a file (biggest win — most fixture setup is identical)
Effort: 30 min for option 1, ~2 hours for option 3.
Context: Noticed during /ship merge wave on garrytan/mcp-key-mgmt (2026-04-16 branch merge of v0.18.0). Failure set stayed exactly 17-18 tests across multiple /ship runs, confirming deterministic flakes rather than real regressions. Blocking workaround: run the specific test file to verify after any suite change.
P1 (new from v0.11.0 — Minions)
Per-queue rate limiting for Minions
What: Token-bucket rate limiting per queue via a new minion_rate_limits table (queue, capacity, refill_rate, tokens, updated_at), with acquire/release in claim().
Why: The #1 daily OpenClaw pain is spawn storms hitting OpenAI/Anthropic rate limits. max_children caps fan-out per parent, but a queue with 50 ready jobs will still slam the API. Every Minions consumer currently reinvents token-bucket in user code.
Pros: First-class rate limiting means no consumer has to roll their own. Composes with max_children (which is per-parent) to give two orthogonal throttles.
Cons: Adds a write hotspot on the rate-limit row. Mitigate by keeping it a simple UPDATE ... WHERE tokens > 0 RETURNING that fails fast and puts the claim back in the pool.
Effort: ~2 hours. Deferred from v0.11.0 to keep the parity PR at a reviewable size.
Depends on: Minions (shipped in v0.11.0).
Minions repeat/cron scheduler
What: BullMQ-style repeatable jobs. queue.add(name, data, { repeat: { cron: '0 * * * *' } }).
Why: Idempotency keys (shipped in v0.11.0) are the foundation. Consumers currently use launchd/cron to fire gbrain jobs submit, but a native scheduler inside the worker would be cleaner and portable across deployments.
Pros: One mental model for both immediate and scheduled work. Idempotency prevents double-fire.
Cons: Every cron library has edge cases (DST, missed intervals on worker restart). Use a battle-tested parser.
Effort: ~1 day.
Depends on: Idempotency keys (shipped in v0.11.0).
Minions worker event emitter
What: worker.on('job:completed', handler) / worker.on('job:failed', ...) instead of polling.
Why: Consumers currently poll getJob(id) to watch state changes. An event API is the ergonomic BullMQ has and Minions doesn't.
Effort: ~4 hours.
waitForChildren(parent_id, n) / collectResults(parent_id) helpers
What: Convenience wrappers over readChildCompletions for common fan-in patterns.
Why: The child_done inbox primitive shipped in v0.11.0. Now add the ergonomic API on top so orchestrators don't have to write the polling loop.
Effort: ~2 hours.
Depends on: child_done inbox primitive (shipped in v0.11.0).
P2
Orchestrator + runner double-write to migrations ledger (deferred from v0.18.2 codex review)
What: src/commands/migrations/v0_18_0.ts:200-208 appends an entry to ~/.gbrain/migrations/completed.jsonl while src/commands/apply-migrations.ts:374-386 also appends one for the same orchestrator run. The dedupe guard in src/core/preferences.ts:120-131 only suppresses duplicate complete entries, not partial entries. Result: distorted wedge counting (3-consecutive-partials-triggers-wedge logic sees 6 partials when it should see 3).
Why: Codex plan-review caught this during PR #356 while verifying the two-migration-systems resume boundary. Not blocking v0.18.2 shipping because it only affects the wedge detection threshold, not correctness of the migration itself.
Fix: Pick one writer (prefer apply-migrations.ts runner as the single source of truth, remove the orchestrator-side append). Fold into feat/agent-migration-devex follow-up PR, which already touches both files for the migrate-command consolidation work.
Depends on: v0.18.2 shipped. ✅
22K-page resync is 30+ minutes on large brains (deferred from v0.18.2 codex review)
What: When a schema migration requires data backfill (e.g., computing page_id from page_slug across all files rows), src/commands/sync.ts:248-251, 311-337 iterates per-file. None of v0.18.2's hardening work shrinks this path. On a 22K-page brain the resync takes 30+ minutes; at 500K pages it would be several hours.
Why: Codex explicitly called out that none of PR #356 or the two follow-up PRs addresses the resync execution model. This is a separate performance-design problem.
Options to explore:
- (a) Parallel page import via worker pool (Minions-based).
- (b) Bulk COPY-based import replacing the per-file INSERT.
- (c) Incremental resync that only rewrites changed rows (needs content hash or updated_at gating).
Priority: P2 now, upgrade to P1 if another heavy migration ships that needs backfill at this scale.
Depends on: v0.18.2 shipped. ✅
Minions: gbrain jobs stats --orphaned (deferred from v0.13.0)
What: New CLI flag / output column surfacing jobs that are waiting with no registered handler on any live worker.
Why: v0.13.0 adds shell jobs that require GBRAIN_ALLOW_SHELL_JOBS=1 on the worker. If an operator submits a shell job but no worker with the flag is running, the row sits in waiting silently. The CLI's starvation warning + docs help at submit time; this TODO surfaces the problem at operational-check time.
Pros: Closes the "did my cron actually run" ambiguity for multi-machine deployments.
Cons: Knowing "no worker has this handler registered" requires worker heartbeat tracking, which Minions doesn't have yet (it's stateless at DB level beyond lock_token). Could be approximated by "no jobs of this name have completed in last N minutes AND count of waiting is > 0."
Depends on: v0.13.0 shell jobs shipped. ✅
Minions: AbortReason plumbing on MinionJobContext (deferred from v0.13.0)
What: Handlers today can't distinguish whether ctx.signal.aborted fired due to timeout, cancel, or lock-loss. v0.13.0 derives this at worker-catch-time from abort.signal.reason, but the handler can't see it directly. Expose ctx.abortReason?: 'timeout' | 'cancel' | 'lock-lost' | 'shutdown' on the context.
Why: Shell handler's kill-sequence today can't decide "retry this" (lock-lost) vs "don't retry, user cancelled" (cancel) — they look the same. A typed AbortReason lets handlers make that decision for themselves.
Pros: Handlers get richer signals.
Cons: Small surface-area addition to the handler API. Not strictly required since the worker already makes the retry/dead decision for them.
Depends on: v0.13.0 shell jobs shipped. ✅
Minions: blocking-mode audit log for true forensic integrity (deferred from v0.13.0)
What: Opt-in mode for shell-audit where appendFileSync failures DO block submission instead of logging-and-continuing.
Why: v0.13.0 ships the audit log in best-effort mode, which means a disk-full attacker can silently disable the forensic trail. Acceptable for v0.13.0 because the primary use is operational ("what did this cron do last Tuesday"), not security forensics. Operators who want fail-closed semantics should have a flag.
Pros: Enables true forensic integrity for deployments that need it.
Cons: Fail-closed means a transient disk issue blocks shell submissions, which can be worse than a missing log line for most operators. Opt-in is the right shape but adds surface area.
Depends on: v0.13.0 shell jobs shipped. ✅
Minions: configurable per-job output buffer sizes (deferred from v0.13.0)
What: Add max_stdout_bytes / max_stderr_bytes to ShellJobParams; override the 64KB/16KB defaults.
Why: 64KB/16KB covers typical OpenClaw scripts today but a verbose benchmark or a debug-dump script could need more.
Depends on: First shell-job author who actually needs it. Don't pre-build the flag.
Security hardening follow-ups (deferred from security-wave-3)
What: Close remaining security gaps identified during the v0.9.4 Codex outside-voice review that didn't make the wave's in-scope cut.
Why: Wave 3 closed 5 blockers + 4 mediums. These are the known residuals. Each is an independent hardening item that becomes trivial as Runtime MCP access control (P0 above) lands.
Items (each a separate small task):
- DNS rebinding protection for HTTP health_checks. Current
isInternalUrlvalidates the hostname string; DNS resolution happens later insidefetch. A malicious DNS server can return a public IP on first lookup and an internal IP on the actual request. Fix: resolve hostname viadns.lookupbefore fetch, pin the IP with a customhttp.Agentlookupoverride, re-validate post-resolution. Alternative: usessrf-req-filterlibrary. - Extended IPv6 private-range coverage. Block
fc00::/7(Unique Local Addresses),fe80::/10(link-local),2002::/16(6to4),2001::/32(Teredo),::/128. Current code covers::1,::, and IPv4-mapped (::ffff:*) via hex hextet parsing. - IPv4 shorthand parsing.
127.1(legacy 2-octet form = 127.0.0.1),127.0.1(3-octet), mixed-radix with trailing dots. Current code handles hex/octal/decimal integer-form IPs but not these shorthand variants. - Broader operation-layer limit caps.
traverse_graphdepthparam, plusget_chunks,get_links,get_backlinks,get_timeline,get_versions,get_raw_data,resolve_slugs— all currently accept unboundedlimit/depth. Wave 3 only clampedlist_pagesandget_ingest_log. sync_brainrepo path validation. Therepoparameter accepts an arbitrary filesystem path. Same threat model asfile_uploadbefore wave 3. AddvalidateUploadPath(strict) for remote callers.file_uploadsize limit.readFileSyncloads the entire file into memory. Trivial memory-DoS from MCP. Add ~100MB cap (matches CLI's TUS routing threshold) and stream for larger files.file_uploadregular-file check. Reject directories, devices, FIFOs, Unix sockets viastat.isFile()beforereadFileSync.- Explicit confinement root (H2).
file_uploadstrict mode currently usesprocess.cwd(). Move toctx.config.upload_root(or derive from where the brain's schema lives) so MCP server cwd can't be the wrong anchor.
Effort: M total (human: ~1 day / CC: ~1-2 hrs).
Priority: P2 — deferred consciously. Wave 3 closed the easily-exploitable paths. These are the defense-in-depth follow-ups.
Depends on: Security wave 3 shipped. None are blockers for Runtime MCP access control, but all three security workstreams (this, that P0, and the health-check DSL) converge on the same zero-trust MCP goal.
Community recipe submission (gbrain integrations submit)
What: Package a user's custom integration recipe as a PR to the GBrain repo. Validates frontmatter, checks constrained DSL health_checks, creates PR with template.
Why: Turns GBrain from a single-author integration set into a community ecosystem. The recipe format IS the contribution format.
Pros: Community-driven integration library. Users build Slack-to-brain, RSS-to-brain, Discord-to-brain.
Cons: Support burden. Need constrained DSL (P1) before accepting third-party recipes. Need review process for recipe quality.
Context: From CEO review (2026-04-11). User explicitly deferred due to bandwidth constraints. Target v0.9.0.
Depends on: Constrained health_check DSL (P1) — SHIPPED in v0.9.3.
Always-on deployment recipes (Fly.io, Railway)
What: Alternative deployment recipes for voice-to-brain and future integrations that run on cloud servers instead of local + ngrok.
Why: ngrok free URLs are ephemeral (change on restart). Always-on deployment eliminates the watchdog complexity and gives a stable webhook URL.
Pros: Stable URLs, no ngrok dependency, production-grade uptime.
Cons: Costs $5-10/mo per integration. Requires cloud account.
Context: From DX review (2026-04-11). v0.7.0 ships local+ngrok as v1 deployment path.
Depends on: v0.7.0 recipe format (shipped).
gbrain serve --http + Fly.io/Railway deployment
What: Add gbrain serve --http as a thin HTTP wrapper around the stdio MCP server. Include a Dockerfile/fly.toml for cloud deployment.
Why: The Edge Function deployment was removed in v0.8.0. Remote MCP now requires a custom HTTP wrapper around gbrain serve. A built-in --http flag would make this zero-effort. Bun runs natively, no bundling seam, no 60s timeout, no cold start.
Pros: Simpler remote MCP setup. Users run gbrain serve --http behind ngrok instead of building a custom server. Supports all 30 operations remotely (including sync_brain and file_upload).
Cons: Users need ngrok ($8/mo) or a cloud host (Fly.io $5/mo, Railway $5/mo). Not zero-infra.
Context: Production deployments use a custom Hono server wrapping gbrain serve. This TODO would formalize that pattern into the CLI. ChatGPT OAuth 2.1 support depends on this.
Depends on: v0.8.0 (Edge Function removal shipped).
P2 (knowledge graph follow-ups)
Auto-link skipped writes generate redundant SQL
What: When gbrain put is called with identical content (status=skipped), runAutoLink still does a full getLinks + per-candidate addLink loop. On N identical writes of a 50-entity page that's 50N round trips.
Why: Defensive reconciliation catches drift between page text and links table, but on truly idempotent writes it's wasted work.
Pros: Lower DB load on cron-style re-syncs. Keeps put_page latency tight under bulk MCP usage.
Cons: Need to track whether links could have drifted independent of content (e.g., a target page was deleted). Conservative approach: only skip auto-link reconciliation if status=skipped AND existing links match desired set (which still requires the getLinks call).
Context: Caught in /ship adversarial review (2026-04-18). Acceptable for v0.10.3 because auto-link runs in a transaction with row locks, so amplification cost is bounded.
Effort estimate: S (CC: ~10min) Priority: P2 Depends on: Nothing.
Audit extract --source db against auto_link config flag
What: gbrain extract links --source db writes to the same links table that auto_link=false is supposed to opt out of. The two are conceptually distinct (extract is intentional batch op, auto_link is implicit on write), but a user who turned off auto_link expecting "no automatic link writes" might be surprised.
Why: Either the behavior should match (extract checks auto_link too) or the docs should explicitly state extract is a superset.
Pros: Less surprise for users who treat auto_link as a master switch.
Cons: Some users want extract to work even when auto_link is off (e.g. one-time backfill).
Context: Caught in /ship adversarial review (2026-04-18). Documenting for now.
Effort estimate: S (CC: ~10min for docs OR ~20min for code change). Priority: P2 Depends on: Nothing.
Doctor --fix polish from v0.14.1 adversarial review
What: Six deferred findings from v0.14.1 ship-time adversarial review on src/core/dry-fix.ts:
- TOCTOU between read and write.
attemptFixreads once, writes later. Concurrent editor saves silently overwritten. Fix: re-read immediately before write and compare snapshot, orO_EXCLtempfile + rename. - Fence detection misses 4-backtick and
~~~fences.isInsideCodeFenceonly catches^```$. CommonMark-legal alternates slip through. expandBulletwalk-up is dead code. Loop breaks immediately becausebaseIndentmatches the current line. Remove or make it actually walk up.- Multi-match guard too strict. Skills with the pattern in a table-of-contents AND body get
ambiguous_multiple_matchesforever. Consider: fix first, re-scan, repeat until fixed-point. - Subprocess spam.
getWorkingTreeStatusspawnsgit statusN×M times perdoctor --fix. Cache per-skill per-invocation. doctor --fix --jsonswallows the auto-fix report.printAutoFixReportreturns early onjsonOutput; agents don't see fix outcomes. Emitauto_fixas a top-level key.
Why: None are ship-blockers; all surfaced during v0.14.1 Codex adversarial review. Bundle into one follow-up PR.
Pros: Closes the adversarial findings loop. Better correctness under concurrent edits and JSON-consumer agents.
Cons: Concurrent-edit test is finicky.
Context: v0.14.1 shipped with the 4 critical fixes (shell-injection via execFileSync, no-git-backup detection, EOF newline preservation, proximity-window consistency). These six are the deferred remainder.
Effort estimate: M (CC: ~45min for all six + tests). Priority: P2 Depends on: Nothing.
Completed
ChatGPT MCP support (OAuth 2.1)
Completed: v0.26.0 (2026-04-25) — gbrain serve --http ships full OAuth 2.1 via MCP SDK's mcpAuthRouter + OAuthServerProvider. Authorization code flow with PKCE unblocks ChatGPT. Client credentials flow unblocks Perplexity/Claude. Dynamic Client Registration available behind --enable-dcr flag (off by default). See docs/mcp/CHATGPT.md for connector setup. Closed the P0 that had been blocking the "every AI client" promise since v0.6.
Implement AWS Signature V4 for S3 storage backend
Completed: v0.6.0 (2026-04-10) — replaced with @aws-sdk/client-s3 for proper SigV4 signing.
Caller-opt-in retry for executeRaw (D3 follow-up from v0.22.1)
What: Add PostgresEngine.executeRawIdempotent(sql, params) (or a {retry: true} parameter flag on executeRaw) so callers explicitly opt into auto-retry for statements they know are idempotent. Audit existing call sites and migrate the read-only ones (search, page fetches, etc.) to the new method.
Why: Closes the gap left by D3's drop-the-wrapper decision in v0.22.1. The original #406 wrapped executeRaw in a regex-gated retry that was unsound for writable CTEs and side-effecting SELECTs. Recovery moved up to the supervisor watchdog, but per-call recovery for reads (the bulk of executeRaw traffic from MCP, search, page fetches) is gone. A caller-opt-in flag puts the idempotency decision where it belongs (at the call site, with full statement context).
Pros: Restores per-call auto-recovery for reads without the phantom-write risk on mutations. Explicit > clever: each call site declares its own idempotency posture. Future caller-added mutations get safe-by-default behavior.
Cons: Touches every existing executeRaw call site (~25). Requires careful audit — accidentally tagging a mutation as idempotent re-introduces the phantom-write bug.
Context: Codex F3 demonstrated that READ_ONLY_PREFIX = /^(\s|--.*\n)*(SELECT|WITH)\b/i is unsound — WITH x AS (UPDATE … RETURNING …) SELECT … matches the prefix but updates a row; SELECT pg_advisory_xact_lock(...) is a SELECT with side effects. The plan-eng-review wrap-up in ~/.claude/plans/system-instruction-you-are-working-tender-horizon.md has the full discussion.
Effort estimate: M (human: ~1 day / CC: ~30 min including call-site audit). Priority: P2 — current behavior (no retry, supervisor recovers within ~3 min) is acceptable but per-call recovery is a real ergonomic win. Depends on: Nothing.
Replace walkMarkdownFiles with engine.getAllSlugs() in extractForSlugs (F1 follow-up from v0.22.1)
What: The cycle path's extractForSlugs() at src/commands/extract.ts:455 still does a walkMarkdownFiles(brainDir) to build the allSlugs set for link resolution. On a 54K-page brain that's a single readdir traversal (~hundreds of ms — acceptable, dominated by the file-content-read elimination from #417). But engine.getAllSlugs() exists at extract.ts:728 and produces the same set via a single SQL query (~tens of ms).
Why: Eliminates the residual directory walk on every cycle. Codex F1 noted that the v0.22.1 plan's "cycle never re-walks the whole tree again" claim was overstated — it stops READING file contents but still walks the directory. This TODO closes that gap honestly.
Pros: Cycle becomes O(slugs sync touched), not O(total brain size). No more readdir on a growing brain. ~5 LOC change.
Cons: Crosses an FS-vs-DB consistency boundary in the FS-source extract path. Edge case: a file deleted from disk but still in DB. Currently extractForSlugs skips with if (!existsSync(fullPath)) continue — unchanged. But if a markdown file references a slug whose page exists in DB but file was deleted, the link would resolve via DB but the original extractor caught it. Needs a careful test for this case.
Context: Codex plan-review during v0.22.1 wrap, verified at extract.ts:455-456. The plan-eng-review session captured the rationale.
Effort estimate: S (human: ~2 hr / CC: ~10 min including the consistency-edge-case test). Priority: P3 — pure perf, no correctness gap. Depends on: Nothing.
err.code-based connection-error matching in postgres-engine.ts (B1 follow-up from v0.22.1)
What: The CONNECTION_ERROR_PATTERNS array (~12 strings: ECONNREFUSED, connection terminated, password authentication failed, etc.) matched against err.message and err.code. Replace with structured matching against err.code only, using postgres.js's typed error classes (PostgresError with structured codes).
Why: String matching against error messages breaks on library upgrades (postgres.js could change its error message phrasing without bumping major). Code matching is durable. The Layer 1 cleanup follows: gbrain itself doesn't define connection-error codes; it should defer to postgres.js's classification.
Pros: More durable across library updates. Less code (drop the 12-string array). Follows the typed-errors pattern v0.21.0 introduced (src/core/errors.ts).
Cons: Requires verifying which err.code values postgres.js actually exposes for each connection-failure mode. May need fallback to message-substring matching for codes that postgres.js doesn't surface.
Context: Section 2/B1 from the v0.22.1 plan-eng-review. After D3 dropped the per-call retry, isConnectionError is no longer in the hot path — only the supervisor watchdog cares about classifying connection errors, and it currently catches anything. This TODO is a cleanup pass when someone next touches that surface.
Effort estimate: S (human: ~2 hr / CC: ~10 min). Priority: P3. Depends on: The above caller-opt-in retry (#1) is the natural co-lander since both touch the same error-classification surface.
remote MCP / HTTP transport (v0.22.7 follow-ups)
Audit-log write amplification on rejected /mcp traffic
What: src/mcp/http-transport.ts writes a row to mcp_request_log for every
incoming /mcp request, including rate-limited (429), oversized (413), and
auth-failed (401) traffic. Under sustained attack the IP rate limit caps audit
writes per IP at 30/min, but at scale (10K distinct IPs) that's still 300K
inserts/min. Two follow-ups: (1) instrument the audit-write rate so we can see
the actual production volume; (2) consider a separate "rejected" table or
sampling for failed-auth rows so the success-path audit table doesn't get
swamped.
Why: Codex flagged this during the v0.22.7 ship adversarial review. We kept the full audit on purpose — forensic data of an attack is valuable — but want to revisit once we have real volume numbers.
Pros: Bounds DB write volume under attack. Keeps the success-path audit table small enough for fast queries.
Cons: Adds a second table or a sampling rule. Not free complexity. Probably not worth it until production hits a real attack pattern.
Context: src/mcp/http-transport.ts:222,235,245 (the three audit-on-reject
call sites) + src/schema.sql:342 (the unbounded table).
Effort estimate: M (human: ~half day / CC: ~30 min once we have volume data).
Priority: P3 — wait for evidence.
Depends on: Production telemetry on mcp_request_log insert rate.
validateParams doesn't check enum values or array item types
What: src/mcp/dispatch.ts:27 (extracted from src/mcp/server.ts in
v0.22.7) only checks top-level JS types. Operations declare enum constraints
(e.g. direction: 'in' | 'out' | 'both') and array items: { type: ... }
schemas in src/core/operations.ts, but validateParams ignores both. Bad
inputs still reach handlers — concretely, an invalid direction falls through
the engine's else branch at src/core/postgres-engine.ts:954, widening
traversal unexpectedly; malformed pages_updated arrays could be written as
garbage JSONB.
Why: Codex flagged this during the v0.22.7 ship adversarial review. The validator was lifted verbatim from the pre-existing stdio path during the dispatch.ts extraction — same gap exists on the stdio MCP server today, so this isn't a v0.22.7 regression. Still worth tightening, since "shared validation" is now the architectural guarantee both transports rely on.
Pros: Better defense-in-depth at the MCP boundary. Catches malformed agent inputs before the engine layer has to.
Cons: Need to walk every operation's param schema and decide which enum violations are user-facing errors vs internal bugs. May need a typed Zod-style schema layer to do this cleanly.
Context: src/mcp/dispatch.ts:27 + src/core/operations.ts (param defs).
Same gap pre-existed on stdio MCP path.
Effort estimate: M (human: ~half day / CC: ~30 min if we use the existing ParamDef shape; XL if a Zod migration is the chosen direction). Priority: P2. Depends on: Whether we want to keep the lightweight ParamDef shape or migrate to typed schemas.
Streaming MCP tool support (re-add SSE based on Accept header)
What: v0.22.7 dropped SSE entirely from gbrain serve --http because no
current MCP tool streams. When the first streaming tool ships (long-running
agent delegation as an MCP tool, resources/subscribe, sampling/createMessage),
re-add SSE in /mcp based on the Accept header per the Streamable HTTP
transport spec. ~30 lines + spec compliance test.
Why: Removing SSE simplified the v0.22.7 transport (one response path, fewer test cases). Adding it back when actually needed is cheap and keeps the code lean in the meantime.
Effort estimate: S (human: ~2 hr / CC: ~15 min). Priority: P3 — wait for the first streaming tool. Depends on: A streaming MCP tool actually existing.
access_tokens.scopes enforcement
What: The access_tokens schema has had a scopes TEXT[] column since
migration v4 (src/core/migrate.ts:84), but nothing enforces it. v0.22.7's
gbrain auth create doesn't accept a --scopes flag, and dispatchToolCall
doesn't gate on scopes. Adding per-tool scope enforcement would let
"claude-desktop-readonly" and "ingest-only" tokens exist.
Effort estimate: M (human: ~1 day / CC: ~30 min for the schema-aware gate). Priority: P3. Depends on: Nothing.