Commit Graph
4 Commits
Author SHA1 Message Date
7267462311 v0.31.4 feat: takes v2 — lessons from 100K-take production extraction (#795)
* feat: takes v2 — lessons from 100K-take production extraction

Consolidates everything learned from the first full takes extraction run
(28,256 pages, 100,720 takes, $361 on Azure GPT-5.5) and subsequent
cross-modal eval (GPT-5.5 + Opus 4.6, scored 6.8/10 overall).

## Fixes

**fix(cli): add recall and forget to CLI_ONLY set**
v0.31 added these commands to handleCliOnly() but forgot the gate set.
Both fell through to cliOps.get() → 'Unknown command'.

**feat(synthesize): auto-enable when corpus dir is configured**
Setting session_corpus_dir is now sufficient — enabled defaults to true
when a corpus dir is set. Explicit enabled=false still wins. Eliminates
the footgun where users configure a corpus dir and nothing happens.

**feat(engine): round takes weights to 0.05 increments**
Cross-modal eval found false precision (0.74, 0.82) implies calibration
accuracy that doesn't exist. Both postgres and pglite engines now round
on insert. 1.0 and 0.0 are preserved exactly.

## Documentation

**docs: takes-vs-facts architectural distinction**
New doc explaining the two epistemological layers, why they must never be
conflated, how the dream cycle consolidate phase bridges them, and
production extraction data (model selection, eval dimensions, key
learnings for extraction prompts).

**docs(takes-fence): clarify holder semantics with eval examples**
Holder = who HOLDS the belief, NOT who it's ABOUT. Expanded JSDoc with
concrete right/wrong examples from the cross-modal eval. Additional
rules: amplification ≠ endorsement, self-reported ≠ verified, founder
describing company → people/founder not companies/slug.

## Tests (17 new, all passing)

- 5 synthesize-enabled-default tests
- 6 takes-holder-semantics tests
- 6 takes-weight-rounding tests

## Cross-Modal Eval Context

| Dimension         | GPT-5.5 | Opus 4.6 | Avg  |
|-------------------|---------|----------|------|
| Accuracy          | 7       | 8        | 7.5  |
| Attribution       | 6       | 7        | 6.5  |
| Weight calibration| 7       | 7        | 7.0  |
| Kind classification| 6      | 7        | 6.5  |
| Signal density    | 7       | 6        | 6.5  |

Top improvements addressed in this PR:
1. Holder vs subject confusion (docs + tests)
2. Weight false precision (runtime enforcement)
3. Takes ≠ facts distinction (architectural doc)
4. Synthesis auto-enable (runtime fix)
5. recall/forget CLI routing (bug fix)

* docs(filing-rules): anchor takes attribution rules (EXP-3)

Adds a "Takes attribution" section to skills/_brain-filing-rules.md
distilling the 6 rules from docs/takes-vs-facts.md into a terse
contract that downstream agents (OpenClaw, Wintermute) can read as
their canonical filing surface.

Documentation only — no in-repo runtime consumer (synthesize.ts reads
the .json file, not the .md). EXP-4 lands the runtime parser-level
holder validation.

Codex review #9: relabels EXP-3 as documentation, not quality work.
The runtime check is EXP-4.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(takes): weight backfill v46 + NaN hardening at 4 sites (EXP-1, Hardening)

Migration v46 (takes_weight_round_to_grid): backfills pre-v0.32 takes.weight
to the 0.05 grid the engine layer (PR #795) enforces on insert. Cross-modal
eval over 100K production takes flagged 0.74, 0.82-style values as false
precision; this brings existing data to the same grid that all new writes
already use.

Tolerance-based comparison (abs > 0.001) avoids the float32-noise re-touch
loop that the naive `weight <> ROUND(...)` form would create — REAL/NUMERIC
comparison promotes weight to DOUBLE PRECISION first, surfacing ~1e-7
representation noise as inequality. The 0.05 grid is 5e-2, so any genuine
off-grid value clears the 1e-3 threshold cleanly.

`transaction: false` (codex review #2 correction): not for mid-statement
resume (a single SQL statement either completes or rolls back). What it
actually buys is freeing the migration runner from holding a long
transaction so other gbrain processes can interleave.

NaN hardening (codex review #8): extracts `normalizeWeightForStorage()` to
takes-fence.ts as a single source of truth used by all 4 takes write sites:
  - pglite-engine.ts addTakesBatch
  - pglite-engine.ts updateTake (was missed in original PR — only clamped,
    didn't round; now rounds AND guards NaN)
  - postgres-engine.ts addTakesBatch
  - postgres-engine.ts updateTake (same fix)

The helper guards `!Number.isFinite()` BEFORE the [0,1] range check (NaN
comparisons are always false, so NaN survived the prior clamp and reached
Math.round(NaN * 20) / 20 = NaN, written through to the DB).

Tests:
- test/migrations-v46-takes-weight-backfill.test.ts: behavioral PGLite test
  (rounding fixture + Codex #2 re-run idempotency + on-grid preservation).
- test/takes-weight-rounding.test.ts: imports the real helper, adds NaN /
  Infinity / -Infinity / null / undefined / updateTake-shape coverage.
- test/migrate.test.ts: structural assertions for v46 SQL shape.

All 52 tests pass; typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(doctor): takes_weight_grid check + pure helper extraction (EXP-2)

Adds doctor's `takes_weight_grid` slice — the post-migration drift detector
for the 0.05 weight grid v0.31 enforces on insert and v46 backfilled.

Codex review #7 corrected the original plan's "extend test/doctor.test.ts
with 3 cases" estimate. runDoctor() is a side-effectful command with
process.exit branches, and the existing tests are mostly source-structure
assertions. The fix: extract `takesWeightGridCheck(engine: BrainEngine)`
as a pure exported function. runDoctor calls it. Tests target the helper
directly with stubbed engines for the missing-table branch and against
real PGLite for the 4 ratio bands.

Branches:
  - 0 takes total → ok ("No takes yet")
  - off_grid / total > 10% → fail (with apply-migrations fix hint)
  - 1% < off_grid / total ≤ 10% → warn (same fix hint)
  - else → ok
  - takes table missing (pre-v37) → warn, graceful skip

Tolerance comparison matches migration v46 (abs > 1e-3) so float32 noise
doesn't make a healthy brain look broken.

Tests (test/doctor.test.ts):
  - takesWeightGridCheck export shape
  - 0-takes branch (avoids divide-by-zero)
  - 100% on-grid via engine.addTakesBatch (which now normalizes)
  - 8/10 off-grid → fail
  - 5/100 off-grid → warn
  - missing-table branch via stub engine

All 21 doctor tests pass; typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(takes): holder runtime validation + producer seam (EXP-4)

Adds parser-level holder grammar enforcement so cross-modal eval's #1
attribution error (holder/subject confusion, scored 6.5/10 across 100K
production takes) shows up as a sync-failure record an operator can see.

Changes:

- src/core/sync.ts: exports SLUG_SEGMENT_PATTERN, the actual character
  class slugifySegment() produces ([a-z0-9._-]). Codex review #3 — the
  initial plan's stricter regex would have warned on legitimate slugs
  like `companies/acme.io` and `people/foo_bar`. HOLDER_REGEX now wraps
  this shared pattern instead of inventing a parallel grammar.

- src/core/takes-fence.ts: HOLDER_REGEX + isValidHolder() helper.
  parseTakesFence() emits TAKES_HOLDER_INVALID warnings for non-matching
  holders. Row preserved (markdown source-of-truth contract).

  Catches the eval's failure modes — `Garry`, `people/Garry-Tan`,
  `world/garry-tan`, `users/garry`, whitespace-only — while keeping
  `companies/acme.io`, `people/foo_bar`, `notes/v1.0.0`-style dotted
  slugs valid. Bare-slug form (`garry`, `alice`) accepted as v0.32 legacy
  compat — production brains shipped with bare-slug holders before the
  namespaced JSDoc landed in PR #795. Reserved for v0.33 promotion.

- src/core/cycle/extract-takes.ts (codex review #4 producer seam): adds
  `failedFiles: Array<{path, error}>` to ExtractTakesResult. Both fs
  and db extraction paths populate it from TAKES_HOLDER_INVALID warnings
  so the migration orchestrator can hand it to recordSyncFailures().
  Without this seam, extending classifyErrorCode would do nothing
  (the regex would have nothing to classify).

- src/commands/migrations/v0_28_0.ts: phaseBBackfill calls
  recordSyncFailures(result.failedFiles, 'migration:v0.28.0-backfill')
  after extractTakes completes. Best-effort — persistence failure
  doesn't fail the backfill phase. Doctor's `sync_failures` check now
  shows TAKES_HOLDER_INVALID=N breakdown after upgrade.

- src/core/sync.ts:classifyErrorCode: extends with TAKES_HOLDER_INVALID
  + TAKES_TABLE_MALFORMED / TAKES_ROW_NUM_COLLISION / TAKES_FENCE_UNBALANCED
  bucket. Previously these warnings bucketed to UNKNOWN.

Tests (test/takes-holder-validation.test.ts — 26 cases):
- Canonical forms (world / brain / people-namespace / companies-namespace)
- Codex #3 dotted-slug + underscore-slug positives
- Legacy bare-slug compat positives
- Eval-flagged error mode rejections (uppercase, mixed case, world/<slug>,
  unrecognized prefix, whitespace, embedded slash)
- HOLDER_REGEX anchoring guard
- SLUG_SEGMENT_PATTERN export shape + drift guard against the wrapping regex
- parseTakesFence end-to-end emission contract
- classifyErrorCode regex coverage

127 tests pass across affected files; typecheck clean. No existing fixtures
broken (legacy bare-slug compat preserves old `garry`-style holders during
the v0.32 transition window).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(eval): gbrain eval takes-quality CLI — DB-authoritative + 4-mode (EXP-5)

Reproducible cross-modal quality eval for the takes layer. Three frontier
models score a sample against the 5-dim rubric, the runner aggregates to
PASS/FAIL/INCONCLUSIVE, the receipt persists to eval_takes_quality_runs.
Trend mode segregates by rubric_version; regress mode is a CI gate that
exits 1 when any dim regresses past --threshold.

Subcommands:
  run     [--limit N --cycles N --budget-usd N --slug-prefix P --models a,b,c]
  replay  <receipt-path> [--json]                 # NO BRAIN required
  trend   [--limit N --rubric-version V --json]
  regress --against <receipt> [--threshold T --json]

Codex review integrations (D7 — all 10 findings landed):

  #1 json-repair shim re-exports BOTH parseModelJSON AND the
     ParsedScore + ParsedModelResult types. The original plan only
     re-exported the function, which would have compile-broken
     cross-modal-eval/aggregate.ts:19's type import.

  #3 Receipt name binds (corpus_sha8, prompt_sha8, models_sha8,
     rubric_sha8) so a future rubric tweak segregates trend rows
     instead of silently corrupting the quality-over-time graph.
     RUBRIC_VERSION + rubric_sha8 are persisted in every receipt.

  #4 Pricing fail-closed: any model not in pricing.ts produces an
     actionable PricingNotFoundError before any HTTP call fires.
     Same drift problem as cross-modal-eval/runner.ts:estimateCost(),
     but explicit instead of silent zero.

  #5 Aggregate requires ALL 5 declared rubric dimensions per model.
     Cross-modal-eval v1's union-of-whatever-parsed pattern allowed a
     model to omit a dim and still PASS — that's a regression-gate
     hole. Now: missing-dim drops the contribution, treated identically
     to a parse failure. Empty-scores PASS regression guard preserved.

  #6 DB-authoritative receipt persistence. Original two-phase plan had
     a split-brain reconciliation gap (disk-success/DB-fail vanishes
     from trend; DB-success/disk-fail unreplayable). Now DB row is the
     source of truth (carries full receipt JSON in a JSONB column);
     disk artifact is best-effort. replay reads disk first; loadReceiptFromDb
     reconstructs from DB when the disk file is missing.

  #10 Brain-routing: replay is the only sub-subcommand that doesn't
      need a brain. cli.ts no-DB bypass routes "eval takes-quality replay"
      directly to runReplayNoBrain, which exits 0/1/2 cleanly without
      ever touching the engine. Other modes go through connectEngine.

Files added:
  src/core/eval-shared/json-repair.ts (hoisted from cross-modal-eval)
  src/core/takes-quality-eval/{rubric,pricing,aggregate,receipt-name,
                                receipt-write,receipt,replay,regress,trend,runner}.ts
  src/commands/eval-takes-quality.ts
  docs/eval-takes-quality.md (stable schema_version: 1 contract)
  10 test files (83 cases — aggregate / receipt-name / shim / pricing /
                 rubric / receipt-write / replay / trend / regress / cli)

Files modified:
  src/cli.ts: replay no-DB bypass + engine-required dispatch
  src/core/cross-modal-eval/json-repair.ts → re-export shim
  src/core/migrate.ts: append v47 (eval_takes_quality_runs table)
  src/core/pglite-schema.ts + src/schema.sql: mirror the v47 table for
    fresh-install path. RLS toggled on the new table.
  src/core/schema-embedded.ts: regenerated via build:schema
  test/migrate.test.ts: 6 structural cases for v47

186 tests pass; typecheck clean. Replay verified working end-to-end
(reads receipt JSON file without DATABASE_URL, exits with the verdict
code, prints actionable error on missing file).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(eval): fill EXP-5 unit-test gaps + test-isolation lint fix

Three additions identified during the test-gap audit:

  1. test/eval-takes-quality-boundaries.test.ts (4 cases):
     - empty corpus → "no takes to evaluate" (pre-LLM)
     - source=fs reserved for v0.33 → clear refusal
     - --budget-usd + unknown model → PricingNotFoundError BEFORE any
       network call (codex review #4 fail-closed contract)
     - --budget-usd null + unknown model → no pre-flight pricing error
       (proves pricing pre-flight gates ONLY when budget is set)

  2. test/eval-takes-quality-runner.serial.test.ts (7 cases):
     End-to-end runner integration with mock.module-stubbed gateway.chat.
     Quarantined as *.serial.test.ts because mock.module leaks across
     files in the same shard process (R2 in check-test-isolation.sh).
     Covers:
       - 3 PASS scores → verdict=pass with all dim scores in receipt
       - all model errors → INCONCLUSIVE
       - 1 success + 2 errors → INCONCLUSIVE (need >=2 contributing)
       - 3 successes with low scores → FAIL
       - budget cap fires before cycle 1 (no chat() ever called)
       - budget cap allows cycle when projection fits

  3. test/eval-takes-quality-receipt-write.test.ts: refactored to use
     withEnv() helper for GBRAIN_HOME mutation instead of direct
     process.env writes. The original beforeAll mutation tripped the
     check-test-isolation.sh R1 lint. withEnv() saves/restores via
     try/finally per-test so other shard files don't see the override.

Verification:
  bun run test       → 4977 pass / 0 fail
  bun run test:serial → 179 pass / 0 fail
  bun run verify     → clean (typecheck + 9 pre-checks pass)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(eval): real-Postgres E2E for eval_takes_quality_runs (EXP-5)

Pure-PGLite tests already cover the receipt-write contract; this E2E
verifies the same code path against actual Postgres so the postgres.js
JSONB encoding and the v47 migration apply cleanly under production
conditions.

Coverage (8 cases):
  - migration v47 created the table with all expected columns
  - writeReceiptToDb persists full receipt_json on Postgres
  - 4-sha UNIQUE constraint enforces ON CONFLICT DO NOTHING idempotency
    (3 inserts → 1 row)
  - rubric_version segregation: distinct rubric_sha8 → distinct row
    (codex review #3 — rubric epoch separation)
  - loadTrend reads in DESC order on Postgres
  - loadReceiptFromDb reconstructs receipt JSON via the JSONB column
  - writeReceipt (combined) succeeds with disk artifact + DB row
  - trend SELECT plan executes (planner picks index on larger tables)

Skips gracefully when DATABASE_URL is unset (existing hasDatabase()
helper). Uses the canonical setupDB/teardownDB from test/e2e/helpers.ts.
GBRAIN_HOME mutation is wrapped in withEnv() per the v0.32.0 test-isolation
lint contract.

Verification:
  bash scripts/run-e2e.sh → 71 files / 499 tests / 0 fail (full E2E suite)
  bun test test/e2e/eval-takes-quality.test.ts → 8 / 8 pass standalone

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: fill v0.32 unit + E2E gap audit (3 new files, 36 cases)

Audit of shipped v0.32 code surfaced 4 wiring gaps that the per-EXP unit
tests didn't cover. Adding direct integration tests for each so a future
refactor can't accidentally bypass the helper or unwire the producer seam.

test/extract-takes-holder-producer-seam.test.ts (7 cases) — codex review
#4 producer seam. Verifies extractTakesFromDb populates ExtractTakesResult.
failedFiles[] when parseTakesFence emits TAKES_HOLDER_INVALID warnings,
and that the entry shape is recordSyncFailures-compatible. Without this
test, the v0_28_0 migration's recordSyncFailures call would have silently
fed it nothing if a refactor accidentally dropped the failedFiles append.
Covers: valid holder (no entry), invalid uppercase, world/<slug>, mixed
valid+invalid, legacy bare-slug compat, malformed-table-only (no leak),
recordSyncFailures shape compatibility.

test/engine-weight-rounding-integration.test.ts (15 cases) — codex review
#8 integration coverage. Helper is unit-tested; this proves both engines'
addTakesBatch + updateTake paths actually call it. PGLite-side coverage
mirrors the test/e2e/takes-weight-rounding-postgres.test.ts E2E for real
Postgres. Covers: 0.74→0.75, 0.82→0.80, on-grid identity, NaN→0.5,
Infinity→0.5, clamp high/low, undefined default, mixed batch order,
updateTake rounds (was unhardened pre-v0.32), updateTake NaN, updateTake
preserves prior weight when undefined.

test/e2e/takes-weight-rounding-postgres.test.ts (6 cases, 14 expects) —
real-Postgres write-path coverage. Specifically tests the postgres.js
unnest() bind path that PGLite doesn't exercise:
  - addTakesBatch rounds via the unnest() bind shape
  - addTakesBatch handles NaN at the postgres.js array marshaling layer
  - 10-row mixed batch (4 off-grid) rounds each independently
  - updateTake rounds on real Postgres
  - updateTake handles NaN
  - migration v48 tolerance matches engine-write tolerance (round-trip
    proof — engine-rounded value is invisible to v48's WHERE clause)

Verification:
  bun run test       → 5166 pass / 0 fail (parallel unit, 128s)
  bun run test:serial → 190 pass / 0 fail
  bun run test:e2e   → 71 / 74 files; 3 pre-existing env-inheritance
                       failures (serve-http-oauth, sources-remote-mcp,
                       thin-client — confirmed identical on master in
                       this environment, documented in CLAUDE.md)
  bun run verify     → clean

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(auth): connect engine in withConfiguredSql; unbreak 3 OAuth E2E suites

Real production bug, not just a test-environment issue.
withConfiguredSql in src/commands/auth.ts created a PostgresEngine via
createEngine() but never called engine.connect(). The PostgresEngine.sql
getter falls back to db.getConnection() (the module-level singleton) when
its instance _sql is unset — and db.connect() wasn't called either.

So every `gbrain auth` subcommand (create, list, revoke, register-client,
revoke-client) crashed with the misleading "No database connection:
connect() has not been called" error on real Postgres. Anyone with a
Postgres-backed brain hit this. The error pointed at gbrain init which
made the regression invisible — users assumed they hadn't initialized.

Verified by running `gbrain auth register-client` directly:
  Before: "Error: No database connection: connect() has not been called."
  After:  "OAuth client registered: ..." with credentials printed.

This fix unblocked all 3 previously-failing E2E suites (which all use
register-client in beforeAll):
  serve-http-oauth.test.ts:    0/28 → 28/28 pass
  sources-remote-mcp.test.ts:  0/14 → 14/14 pass
  thin-client.test.ts:         0/7  →  6/7 pass + 1 documented skip

Two surgical test-side fixes also landed:

1. test/e2e/thin-client.test.ts:182 — assertion typo. Test expected
   r.stderr to contain "thin client" (space). Actual refusal message
   says "(thin-client of <url>)" with hyphen. Loosened to /thin[- ]client/
   so a future format tweak doesn't false-fail.

2. test/e2e/thin-client.test.ts:239 — skipped "remote ping triggers
   autopilot-cycle" with a clear TODO. Test asks the wrong question
   against the existing fixture: `gbrain serve --http` deliberately
   does NOT start a job worker (workers run via separate `gbrain jobs
   work` process), so the submitted autopilot-cycle job sits in
   `waiting` forever. Test was supposed to fall back to the self-imposed
   `--timeout`, but `gbrain remote ping --timeout` doesn't honor the cap
   when callRemoteTool hangs (loop only checks elapsed time between
   iterations; a single in-flight callTool with no AbortSignal blocks
   forever). Two real follow-ups would unblock: thread an AbortSignal
   through callRemoteTool's MCP callTool path, OR start a `gbrain jobs
   work` subprocess in beforeAll. Either is its own PR. Wire path
   coverage isn't lost — exercised by every other test in this file
   plus the entire serve-http-oauth.test.ts suite.

Verification:
  bun test test/e2e/serve-http-oauth.test.ts test/e2e/sources-remote-mcp.test.ts test/e2e/thin-client.test.ts
    → 47 pass / 1 skip / 0 fail in 8.4s
  bun run verify → clean

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: garrytan-agents <garrytan-agents@users.noreply.github.com>
Co-authored-by: Garry Tan <garrytan@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 06:34:40 -07:00
+10 ff53a4c9bc v0.31.1.1-fixwave fix-wave: 22 community fixes (auth-code P0, upgrade-path, sync, multi-source, privacy) (#776)
* fix: bootstrap forward-references for v39-v41 schema replay

Three column-with-index forward references in the embedded schema blob were
missing from applyForwardReferenceBootstrap, so any brain at config.version
< 39 (Postgres) or < 41 (PGLite) wedges before the migration runner can
advance. Reproduced end-to-end on a PlanetScale Postgres brain stuck at
config.version=34 trying to upgrade to v0.30.0:

  ERROR: column "effective_date" does not exist
  ERROR: column cc.modality does not exist

(After upgrading, gbrain search and gbrain reindex-frontmatter both fail.)

The schema-blob references that crash before migrations run:

- v39 (multimodal_dual_column_v0_27_1):
    CREATE INDEX idx_chunks_embedding_image
      ON content_chunks USING hnsw (embedding_image vector_cosine_ops)
      WHERE embedding_image IS NOT NULL;
- v41 (pages_recency_columns):
    CREATE INDEX pages_coalesce_date_idx
      ON pages ((COALESCE(effective_date, updated_at)));

PGLite already covered v39 (lines 273+, 308+, 382-392). Postgres and PGLite
both lacked v40+v41 coverage. This commit adds:

- Postgres engine probe + branch for v39 (modality, embedding_image) — was
  entirely missing on Postgres, so Postgres brains < v39 hit the wedge that
  PGLite already protected against.
- Both engines: probe + branch for v40+v41. Bootstraps all five additive
  pages columns (emotional_weight, effective_date, effective_date_source,
  import_filename, salience_touched_at) gated on `effective_date_exists`
  as the proxy.
- test/schema-bootstrap-coverage.test.ts: extends REQUIRED_BOOTSTRAP_COVERAGE
  with the six new columns AND the pre-test DROP block so both the per-target
  assertion test and the end-to-end "bootstrap + SCHEMA_SQL replay" test
  exercise the new coverage.

All 5 tests in schema-bootstrap-coverage pass. typecheck clean.

Bootstrap stays additive-columns-only. Indexes are left to schema replay /
migrations as before.

* fix(deps): declare @jsquash/png and heic-decode

Both packages are direct imports in src/core/import-file.ts (decodeIfNeeded
for HEIC/AVIF → PNG) but only @jsquash/avif was declared. bun --compile
fails on a fresh install:

  error: Could not resolve: "@jsquash/png/encode.js"
  error: Could not resolve: "heic-decode"

Adds the missing declarations so npm install / bun install bring them in.

Versions chosen as latest at time of fix:
  @jsquash/png  ^3.1.1
  heic-decode   ^2.1.0

* fix(backfill-effective-date): replace bare BEGIN/COMMIT with engine.transaction()

postgres.js refuses bare BEGIN/COMMIT on pooled connections with
UNSAFE_TRANSACTION. The migration runner and other call sites already
use engine.transaction() (which routes through sql.begin() with a
reserved backend) — backfill-effective-date.ts was the holdout.

Reproduces on PlanetScale Postgres (us-east-4.pg.psdb.cloud) running
the v0.29.1 orchestrator's Phase B against a brain that has any rows
needing backfill:

  Reindex ok ... UNSAFE_TRANSACTION: Only use sql.begin, sql.reserved or max: 1

Switches the per-batch transaction to engine.transaction(async tx => …).
The SET LOCAL statement_timeout still scopes to the transaction; UPDATE
runs through the tx-scoped engine. ROLLBACK on error happens
automatically via sql.begin's contract.

Equivalent fix shape to existing usages in src/core/postgres-engine.ts
(lines 703, 806, 925) and the migration runner in src/core/migrate.ts
(line 2147).

* fix(v0_29_1): connect engine before use in Phase B and Phase C

phaseBBackfill() and phaseCVerify() build their own engine via
createEngine(toEngineConfig(cfg)) but never call engine.connect().
This worked accidentally before because executeRaw lazily falls back
to db.getConnection(), but engine.transaction() (added in the
companion backfill fix) requires a connected backend and surfaces
the missing-connect with:

  No database connection: connect() has not been called.
  Fix: Run gbrain init --supabase or gbrain init --url <connection_string>

Other orchestrators in the same directory get this right —
v0_28_0.ts:181 already does `await engine.connect(engineConfig)`
right after createEngine. Aligning v0_29_1 with that pattern.

After this + the backfill fix, v0.29.1 orchestrator runs to
'complete' on a fresh upgrade with backfill-needed rows, instead
of wedging at 'partial' status.

Note: anyone hitting the wedged state after the prior failures will
need `gbrain apply-migrations --force-retry 0.29.1` once before the
next apply-migrations --yes succeeds (the 3-consecutive-partials
guard in apply-migrations.ts is still active).

* fix: connect engine in v0.29.1 migration

* fix(upgrade): detectBunLink fails because bun resolves symlinks in argv[1]

bun resolves the entire symlink chain before setting process.argv[1],
so lstatSync(argv1).isSymbolicLink() always returns false for bun-link
installs, short-circuiting the git-config walk that would correctly
identify the repo. Remove the symlink gate — argv[1] is already the
real path inside the checkout, which is what the walk needs.

Also: return { repoRoot } so the upgrade path can auto-execute
git pull + bun install via execFileSync (no shell injection surface).

Fixes #368, supersedes incomplete v0.28.5 fix for #656.

* fix(oauth): clamp authorize() requested scopes against client.scope (RFC 6749 §3.3)

The MCP SDK's authorize handler (`@modelcontextprotocol/sdk/.../auth/handlers/authorize.js`)
splits `?scope=...` verbatim and forwards the parsed list to the provider, so the
provider has to clamp against the client's registered grant. v0.28.11
`authorize()` (src/core/oauth-provider.ts:235-259) inserted `params.scopes || []`
raw into `oauth_codes`, so a `read`-registered client requesting
`?scope=admin` had `['admin']` stored and `exchangeAuthorizationCode` issued
a fully-admin access token at /token exchange.

The asymmetry is the bug: the other two grant entry points already clamp.
`exchangeClientCredentials` (line 513-515) filters requested scopes through
`hasScope(allowedScopes, s)`, and `exchangeRefreshToken`'s F3 (line 372-380)
enforces RFC 6749 §6 subset against the original grant. authorize() lined up
with neither.

Fix mirrors the client_credentials filter shape so all three grant entry
points clamp consistently:

    const allowedScopes = parseScopeString(client.scope);
    const grantedScopes = (params.scopes || []).filter(s => hasScope(allowedScopes, s));

Empty/omitted requested scope keeps storing `[]` (existing shape, not a
security boundary). The clamped subset is what the client sees in the
`scope` field of the token response, which is the spec-compliant signal
that the grant was reduced.

Test coverage:
- New: authorize clamps requested scopes against client.scope (RFC 6749 §3.3)
  — read-only client requests ['read','write','admin'] and the issued token
  carries only ['read'].
- New: authorize subset request returns subset — 'read write' client
  requesting ['read'] gets ['read'] (regression guard against over-clamping).

The existing v0.26.9 oauth.test.ts pins F3 (refresh clamp) but had no
authorize-side coverage, which is why the regression survived.

* fix(sync): handle detached HEAD by skipping pull and ingesting local working tree

* fix(sync): --skip-failed acks pre-existing unacked failures up-front

The recovery flow that doctor + printSyncResult both advertise was broken:

1. User has files with bad YAML → they hit the failure log + sync stays
   blocked at last_commit.
2. User fixes the YAML.
3. User re-runs `gbrain sync` — sync succeeds, advances last_commit.
4. `gbrain doctor` still reports N unacked failures from step 1 because
   sync-failures.jsonl is append-only history, never auto-cleared.
5. doctor message says: "use 'gbrain sync --skip-failed' to acknowledge".
6. User runs `gbrain sync --skip-failed` → "Already up to date." → log
   unchanged.

The bug: --skip-failed only acknowledges failures from the CURRENT run.
performSync's ack path is gated on `failedFiles.length > 0` after sync —
it never fires when the diff is empty (because the user already fixed
the bad files) or when the sync is up to date. So the documented recovery
sequence is a no-op exactly when the user needs it.

The fix: at the top of runSync, when --skip-failed is set, eagerly ack
any pre-existing unacked failures before any sync work runs. Now the flag
means "acknowledge whatever is currently flagged and move on" regardless
of whether the current run produces new failures or finds nothing to do.

The inner per-run ack path stays — it still handles new failures from
the CURRENT run, which is the (a) syncing now produces failures + (b)
caller wants to ack them path. The two paths compose: `gbrain sync
--skip-failed` clears stale + advances past anything new, all in one
command, matching what the doctor message promises.

Tests: 2 added in test/sync-failures.test.ts. One source-string pin on
the new gate (the file's existing pattern for CLI-flag tests). One
behavioral test on the underlying acknowledgeSyncFailures path.

Repro:
  $ gbrain doctor
  [WARN] sync_failures: 27 unacknowledged sync failure(s)...
         Fix the file(s) and re-run 'gbrain sync', or use
         'gbrain sync --skip-failed' to acknowledge.
  $ # ... fix the YAML ...
  $ gbrain sync
  Already up to date.
  $ gbrain sync --skip-failed
  Already up to date.   # before this PR
  $ gbrain doctor
  [WARN] sync_failures: 27 unacknowledged sync failure(s)...   # still!

After:
  $ gbrain sync --skip-failed
  Acknowledged 27 pre-existing failure(s).
  Already up to date.
  $ gbrain doctor
  [OK] sync_failures: N historical sync failure(s), all acknowledged

* fix(extract): default --dir to configured brain dir, not cwd

`gbrain extract links` (and timeline / all) defaulted --dir to '.' when
not explicitly passed (src/commands/extract.ts:357). Combined with a
walker that skips dotfiles but NOT node_modules/dist/build/vendor, this
turned a no-arg invocation into a footgun.

Repro:
  $ cd ~/Documents/some-project   # has a node_modules/ tree
  $ gbrain extract links
  [extract.links_fs] 28989/28989 (100%) done
  Links: created 0 from 28989 pages
  Done: 0 links, 0 timeline entries from 28989 pages

The "28989 pages" is `walkMarkdownFiles('.')` recursively eating package
READMEs, dependency docs, fixture content. Their from_slug doesn't match
any row in the pages table, so addLinksBatch rejects every insert and
returns 0. Output looks like a healthy idempotent no-op; was actually a
wasteful junk walk that wrote nothing.

Fix: when --dir is not passed AND source is fs, resolve from
sources(local_path) via getDefaultSourcePath — same helper sync uses
(src/commands/sync.ts:1089). The default behavior now matches `sync`:
"work on the configured brain". Falls back to a clear error when no
source is configured, telling the user to either pass --dir, register
a source, or use --source db.

Behavior matrix:
  --dir explicit     → use that path (unchanged)
  --dir absent + cfg → resolve from sources(local_path)
  --dir absent + no  → error with actionable hint (was: walk cwd silently)
  --dir .            → cwd (user opted in explicitly — unchanged)

Tests: three added in test/extract-fs.test.ts:
  1. configured source → no-arg invocation extracts from that path
  2. no source configured → exit 1 + actionable error message
  3. explicit --dir wins over a configured (decoy) source path

* fix(extract): normalize slugs to lowercase via pathToSlug() (T-OBS-1)

The extractor was generating from_slug and the allSlugs lookup set from
`relPath.replace('.md', '')` in 5 places, producing CAPS slugs for files
named ETHOS.md, AGENTS.md, ROADMAP.md, etc.

Pages persist in the DB with lowercase slug (core/sync.ts pathToSlug()
applies .toLowerCase()). The CAPS extractor output mismatched the DB rows,
so INSERT ... JOIN pages ON pages.slug = v.from_slug silently dropped
links from CAPS-named source files. The link batch returned 'inserted'
counts that were lower than the wikilinks actually present, with no error.

Reproduction (in a brain with CAPS-named canonical docs):
  1. echo 'See [agents](agents.md).' > ETHOS.md
  2. gbrain put ethos < ETHOS.md  # page row: slug='ethos'
  3. gbrain extract links --source fs
  4. gbrain backlinks agents → []  (expected: contains 'ethos')

Fix: import pathToSlug from core/sync.ts and use it in all 5 sites:
  - extractLinksFromFile (line 200): from_slug derivation
  - runIncrementalExtractInternal (line 456): allSlugs set
  - extractLinksFromDir (line 552): allSlugs set
  - timeline loop (line 643): from_slug for timeline entries
  - extractLinksForSlugs (line 673): allSlugs set used by sync hook

This single-line-per-site change keeps the extractor consistent with the
sync layer's slug normalization and doesn't introduce any new behavior
for already-lowercase paths (idempotent).

Tests: added 'extractLinksFromFile — slug normalization (T-OBS-1
regression)' suite with 4 cases covering CAPS, mixed-case, idempotent
lowercase, and nested path. Full extract suite (54 → 58 tests) passes.

Reported by Claude Code (Opus 4.7) during Obsidian PKM integration on
the gstack-plan Living Repo, where ~111 wikilinks pointing to ETHOS,
AGENTS, ROADMAP, etc. failed to count toward brain_score (54/100 vs
expected 75+/100). Documented as T-OBS-1 in the consumer's blocked.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(cli): CLI_ONLY commands should short-circuit on --help instead of executing

* fix(doctor): correct command syntax in graph_coverage warn message

graph_coverage warn directs users to run `gbrain link-extract &&
gbrain timeline-extract`, but no commands by those names are
registered in cli.ts. The actual commands are `gbrain extract links`
and `gbrain extract timeline` (registered as the 'extract'
subcommand at src/cli.ts:525, with the kind argument 'links' /
'timeline' / 'all' parsed inside src/commands/extract.ts).

A user who runs the suggested command gets:
  $ gbrain link-extract
  Unknown command: link-extract

This is the only place in src/ with the wrong syntax — the rest of
the docs (init.ts:221, init.ts:331, features.ts:120,
v0_13_0.ts:67, sync.ts:752 comment) all already say 'extract links'.
This patch just brings doctor.ts in line.

* fix(doctor): use autoDetectSkillsDir so OpenClaw workspaces are reachable

`gbrain doctor` was the only consumer of `findRepoRoot` from
`core/repo-root.ts`. Every other consumer (check-resolvable.ts:145,
skillify.ts, etc.) uses `autoDetectSkillsDir`, which has the full
detection chain:
  1. \$OPENCLAW_WORKSPACE
  2. ~/.openclaw/workspace
  3. findRepoRoot() walk from cwd
  4. ./skills

`findRepoRoot` only does step 3. Result: when the user runs `gbrain
doctor` from any directory outside the gbrain repo or the OpenClaw
workspace tree (e.g., a project's checkout), `resolver_health` reports
"Could not find skills directory" even though the dispatcher exists at
~/.openclaw/workspace/skills/RESOLVER.md.

Reproduces in any directory other than ~/gbrain or its descendants on
a system with ~/.openclaw/workspace/skills/RESOLVER.md present:

    \$ cd ~/Documents
    \$ gbrain doctor
    [WARN] resolver_health: Could not find skills directory   # before
    [WARN] resolver_health: 5 issue(s): 0 error(s), 5 warning(s)  # after

Switching doctor to `autoDetectSkillsDir` brings it inline with the rest
of the codebase. The detected dir is also passed to
`checkSkillConformance` (step 2 of the resolver_health block), which
previously rebuilt the path from `repoRoot` — now uses the same
detected path for consistency.

All 15 existing tests in test/doctor.test.ts continue to pass.

* fix(mcp): exit serve process on stdin-close/SIGTERM

MCP stdio server was keeping the bun process alive indefinitely after
the client disconnected. Over days this accumulated 20+ orphaned
gbrain serve processes, all holding the PGLite directory open.
Since PGLite is single-writer, this caused write-lock contention that
made email-sync fail its 15s per-put timeout: 114 puts x 15s = 28.5min
runs with 0 emails written.

Now listens for stdin end/close, transport close, and SIGTERM/SIGINT/
SIGHUP; calls engine.disconnect() and exits cleanly.

Root cause for the no-gbrain-run-in-50h alert.

* fix(skills): broaden RESOLVER triggers + 1 ambiguity flag (37 misses → 0, 100% top-1 accuracy)

`bun run src/cli.ts routing-eval` was reporting 37 ROUTING_MISS entries
across 10 skills whose RESOLVER.md trigger phrases didn't match any of
their own routing-eval.jsonl fixture intents. Two distinct causes:

1. Single-phrase triggers in 9 skills under '## Uncategorized' didn't
   cover the paraphrased fixture variations they're supposed to route.
   Broadened each trigger cell to a quoted-phrase list that covers the
   fixtures (5 fixtures per skill on average).

2. The media-ingest row used unquoted prose
   ('Video, audio, PDF, book, YouTube, screenshot') which
   extractTriggerPhrases() collapses into one impossible long phrase
   ('video audio pdf book youtube screenshot') under normalizeText —
   no fixture intent will ever contain that exact substring. Converted
   to a quoted phrase list.

3. One fixture ('web research pass on this person') legitimately
   matches both `perplexity-research` and `data-research`
   (data-research's trigger row contains "Research"). Marked the
   fixture `ambiguous_with: ["data-research"]` since the overlap
   on the keyword 'research' is inherent and expected.

Skills with broadened triggers:
  - voice-note-ingest, article-enrichment, book-mirror,
    archive-crawler, brain-pdf, academic-verify, concept-synthesis,
    perplexity-research, strategic-reading, media-ingest

Before: 58 cases, 37 misses, ~36% top-1 accuracy
After:  58 cases, 0 misses, 100% top-1 accuracy

This also clears `gbrain doctor`'s `resolver_health: 37 issue(s)` warning.

* fix(multi-source): thread source_id through per-page tx surface

Multi-source brains crashed mid-import with Postgres 21000 ("more than one
row returned by a subquery used as an expression"). Root cause: putPage's
INSERT column list omitted source_id, so writes intended for a non-default
source (e.g. 'jarvis-memory') silently fabricated a duplicate row at
(default, slug). The schema has UNIQUE(source_id, slug) but DEFAULT 'default'
for source_id; calling putPage(slug, page) without source_id landed at
(default, slug) and ON CONFLICT updated the wrong row, leaving the intended
source row stale. Subsequent bare-slug subqueries inside the same tx —
(SELECT id FROM pages WHERE slug = $1) in getTags / removeTag / deleteChunks
/ removeLink / addLink (cross-product) — then matched 2 rows and crashed
with 21000, rolling back the entire import. Observed: 18 sync failures
against a 'jarvis-memory'-sourced brain.

Fix:
- putPage adds source_id to the INSERT column list (defaults 'default' for
  back-compat).
- Every bare-slug page-id subquery becomes source-qualified
  (AND source_id = $X) in both engines: createVersion, upsertChunks,
  getChunks, addTag, removeTag, getTags, deleteChunks, removeLink,
  addTimelineEntry, deletePage, updateSlug.
- addLink rewritten away from FROM pages f, pages t cross-product into a
  VALUES + JOIN-on-(slug, source_id) shape mirroring addLinksBatch.
- engine.ts interface: 11 method signatures gain optional opts.sourceId
  (or opts.{from,to,origin}SourceId for addLink/removeLink). All optional;
  existing callers default to source='default' and behave identically.
- import-file.ts: importFromContent / importFromFile / importCodeFile take
  opts.sourceId and thread txOpts = { sourceId } through every per-page tx
  call. engine.getPage callsite source-scoped for accurate idempotency.
- commands/sync.ts: thread opts.sourceId at importFile (line 581 + 641),
  un-syncable cleanup (487-498), delete phase (557), rename phase (574),
  and post-sync extract phase (815-816).
- commands/reindex-code.ts: thread opts.sourceId at importCodeFile call.
- commands/extract.ts: extractLinksForSlugs / extractTimelineForSlugs accept
  opts.sourceId and propagate via linkOpts / entryOpts.
- commands/reconcile-links.ts: ReconcileLinksOpts.sourceId was declared but
  ignored end-to-end; now wired through getPage + addLink calls.
- commands/migrate-engine.ts: --force wipe switched to executeRaw('DELETE
  FROM pages') to preserve the pre-PR all-sources semantic after deletePage
  became default-source-scoped.

Regression test: test/source-id-tx-regression.test.ts (19 tests). Validates
two sources × same slug coexist; getTags/addTag/removeTag/deleteChunks/
upsertChunks/createVersion/addLink/addTimelineEntry/deletePage/updateSlug
source-scoped writes don't 21000; back-compat without opts targets
source='default'; addLink fail-fast on missing source-qualified endpoint;
importFromContent end-to-end tx thread without fabricating duplicate.

Adversarial review: Codex (gpt-5.5 reviewer) + Grok (xAI flagship reviewer)
3-round crew loop. Round 1: 2 HIGH (addTimelineEntry + extract.ts thread)
+ 2 MED. Round 2: 1 CRITICAL + 1 HIGH (deletePage + updateSlug bare-slug)
+ 2 MED. Round 3: 2 HIGH (getChunks + migrate-engine semantic regression
introduced by R2 fix). Round 4: both reviewers CLEAR.

Deferred to follow-up PRs (noted as TODO):
- src/commands/embed.ts source-aware threading (auto-embed at sync.ts:823
  has a TODO; try/catch swallows the failure as best-effort).
- src/core/postgres-engine.ts:1511 / pglite-engine.ts:1446 putRawData
  bare-slug (lower-impact metadata path).
- Read-surface bare-slug consistency cleanup (getLinks/getBacklinks/
  getTimeline/getRawData/getVersions): non-mutating, won't 21000.
- reconcile-links.ts CLI --source flag exposure (internal opt is wired;
  CLI parser is a UX feature for later).

Existing rows in production written under (default, slug) by the old
putPage when caller meant another source remain misrouted. Backfill
heuristics need install-specific knowledge of intended source and are
outside this PR's scope; surface as a deployment-side cleanup task.

bun run typecheck clean, bun run build clean, 19/19 regression tests pass,
4082 unit pass / 1 pre-existing fail (BrainRegistry test depending on
test-env ~/.gbrain/ absence — fails on untouched main, unrelated).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(multi-source): plumb sourceId through performFullSync (PR #707 gap)

PR #707 fixed source_id routing for sync's incremental loop (lines 581/641)
but performFullSync (line 922) calls runImport without threading sourceId.
Result: full syncs route pages to default even with --source <id>. Verified
on v0.30.1 by direct PGLite probe after `gbrain sync --source X --full`:
all pages landed in default, not the named source.

Fix:
- runImport accepts sourceId in opts (programmatic only — no CLI flag,
  preserving PR #707's design intent of `gbrain import` being default-only).
- runImport threads sourceId to importFile + importImageFile.
- performFullSync passes opts.sourceId to runImport.
- ImportImageOptions type accepts sourceId for runImport branch (importImageFile
  body wiring deferred — image imports out of scope for current use case;
  TS error fix only).

Verified: real sync test against /tmp/test-sync routes 1 page to "testsync"
source, 0 to default (post-fix). 19/19 source-id regression tests still pass.
Typecheck clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test: regression test for performFullSync sourceId threading

PR #707's existing 19-test suite at test/source-id-tx-regression.test.ts
covers the engine-layer transaction surface (putPage / addTag / etc.)
but does NOT exercise commands/sync.ts:performFullSync. Verified via
`grep -c 'performFullSync' test/source-id-tx-regression.test.ts → 0`.

This means the +18/-4 fix at sync.ts:892 (performFullSync passing
sourceId to runImport) had no automated coverage.

Adds 2 PGLite-only regression tests:

1. `performFullSync with --source routes pages to named source (not default)`
   — fixture: temp git repo with 2 markdown files. Calls performSync with
   { full: true, sourceId: 'testsrc-pfs', noPull: true, noEmbed: true }.
   Asserts pages.source_id = 'testsrc-pfs', not 'default'. Pre-fix: FAILS
   (verified by checking out 46cd197 — rebased PR #707 only, without my
   gap-fix — and running this test). Post-fix: PASSES.

2. `performFullSync WITHOUT --source still targets default (back-compat)`
   — same fixture, no sourceId opt. Asserts pages.source_id = 'default'.
   Both pre-fix and post-fix: PASSES (back-compat preserved by the fix).

Verified: 21/21 tests pass on this branch (19 from PR #707 + 2 new).
`bun run typecheck` clean. `bun run verify` clean (8 guard checks pass).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(privacy): strip takes fence from get_page / get_versions when token carries an allow-list

v0.28.6 (#563) introduced the per-token takes-holder allow-list: an OAuth token
carries `permissions.takes_holders` and `takes_list` / `takes_search` /
`think.gather` filter take rows server-side via `WHERE t.holder = ANY($allowList)`
in both engines.

But take rows are stored in two places per the explicit contract in
`extract-takes.ts:5-13` ("markdown is canonical, the takes table is a derived
index"): the structured `takes` table AND inline in `pages.compiled_truth`
between `<!--- gbrain:takes:begin -->` markers as a markdown table whose `who`
column IS the holder. A read-only token whose `takes_holders` is `["world"]`
(the documented default-deny posture from migrate.ts:1221) can call
`get_page <slug>` and recover every non-`world` claim verbatim from the body —
private hunches, founder bets, non-public sourcing notes. `get_versions` has
the same shape: snapshots persist historical compiled_truth verbatim, so a
caller blocked at `get_page` falls through to /history.

The team already shipped a complementary fix in `chunkers/recursive.ts:49`
(stripTakesFence applied before the body is chunked, so `query` results don't
leak fence content). Migration v38 documents this as a "complementary fix" —
the page-CRUD surface was missed.

Fix strips the fence at the op layer when `ctx.takesHoldersAllowList` is set
(i.e. the remote MCP path). Local CLI callers leave the field unset and keep
seeing the full fence.

    const visibleBody = ctx.takesHoldersAllowList
      ? { ...page, compiled_truth: stripTakesFence(page.compiled_truth) }
      : page;

Same shape on `get_versions` over every snapshot in the array. Re-rendering
the fence with allow-list-filtered rows would require joining the takes table
per version_id and inverts the markdown-canonical contract; whole-fence strip
is the conservative posture that closes the leak. A future allow-list-aware
re-render is an additive change that won't break the contract pinned by these
tests.

Test coverage in `test/takes-mcp-allowlist.serial.test.ts`:
- get_page with allow-list strips fence; surrounding body kept.
- get_page without allow-list (local CLI) keeps fence (back-compat).
- get_page fuzzy resolution path also strips for remote tokens.
- get_versions with allow-list strips fence on every snapshot.
- get_versions without allow-list returns historical content intact.

The pre-fix R12 PoC reported `LEAKED garry hidden take? YES` and
`LEAKED brain hidden take? YES`; post-fix the same PoC reports `no` for both
holders and "bypass did not reproduce".

* Fix double-encoded jsonb in subagent_tool_executions breaking slug lookup

persistToolExecPending/Failed/Complete called JSON.stringify(input) before
passing to a $N::jsonb parameter. When input is already an object, this
produces a JSON string which ::jsonb stores as a jsonb scalar -- not a
jsonb object. Downstream queries like input->>slug then return NULL
because the operator does not traverse scalar strings.

Root cause fix: skip JSON.stringify when input is already a string.

Query fix: use COALESCE with (input #>> '{}')::jsonb->>slug fallback
to handle both old double-encoded rows and new properly-encoded rows.

Affects: dream cycle synthesize phase (pages_written always 0) and
patterns phase (same slug collection query).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(adapter/voyage): translate request/response between OpenAI-compat SDK and Voyage's actual contract

The @ai-sdk/openai-compatible package treats Voyage as if it were
OpenAI-shaped, but Voyage's /v1/embeddings endpoint diverges in three places
that combine into a hard-blocking incompatibility:

OUTBOUND request:
  - 'encoding_format=float' (SDK default) is rejected; Voyage only accepts 'base64'
  - 'dimensions' parameter (OpenAI name) is rejected; Voyage uses 'output_dimension'

INBOUND response:
  - With encoding_format=base64, 'embedding' is returned as a base64 string,
    but the SDK's Zod schema (openaiTextEmbeddingResponseSchema) expects an
    'array of number'. The schema fails with 'Invalid JSON response' even
    though the JSON is well-formed.
  - 'usage' lacks 'prompt_tokens'; the schema requires it when usage is present.

Without this patch, ALL embedding requests to Voyage fail. Reproducible by
running 'gbrain put <slug> < text' with embedding_model=voyage:voyage-* and
any current voyage model (voyage-3-large, voyage-3, voyage-4-large).

Solution: pass a custom 'fetch' to createOpenAICompatible only when
recipe.id === 'voyage'. The fetch wrapper:
  1. Forces encoding_format='base64' on outbound (Voyage's only accepted value)
  2. Translates dimensions -> output_dimension on outbound
  3. Drops Content-Length so the runtime recomputes from the mutated body
  4. Decodes base64 embeddings to Float32 arrays on inbound (so the Zod schema
     sees what it expects)
  5. Synthesizes prompt_tokens from total_tokens when missing

This is a minimal, targeted fix. It only activates for Voyage and falls
through cleanly for all other providers. No public API changes.

* feat(dream): support .md files in transcript discovery

Transcript discovery only accepted .txt files. Many brain repos store
meeting transcripts and conversation logs as .md (markdown), which is
the natural format for brain content.

Changes:
- listTextFiles() now accepts both .txt and .md
- basename extraction handles both extensions for date inference
- readSingleTranscript() handles both extensions

No behavior change for existing .txt-only setups.

* fix(test): cast exitCode to unknown for TS strict-narrowing

TS narrows exitCode to null between declaration and assertion because
the mocked process.exit is behind `(process as any).exit`. The cast
preserves test intent without weakening the variable's type annotation.

Wave-side merge fix; ships alongside #688 (extract --dir default).

* fix(cli): add frontmatter + check-resolvable to CLI_ONLY_SELF_HELP

Companion to #634. Both commands have their own --help logic that prints
detailed usage with command-specific flags (e.g., --json, --fix, --strict
for check-resolvable). Without this, pr-634's generic short-circuit prints
"Usage: gbrain <cmd> - run gbrain --help for the full command list." and
the existing --help integration tests fail.

Verified: `gbrain frontmatter --help` and `gbrain check-resolvable --help`
now route to their handlers, which print full per-command usage and exit 0.

* fix(test): update discoverTranscripts test expectation for .md support

Companion to #708. The pre-#708 test asserted that .md files in the
session-corpus directory were skipped. Post-#708 they are discovered
alongside .txt. Renamed the test to 'skips non-txt non-md files' (uses
.pdf as the negative case) and added a positive .md discovery test that
pins #708's intended behavior.

* fix(skills): declare missing RESOLVER triggers in skill frontmatter

Companion to #718. The RESOLVER round-trip test (test/resolver.test.ts)
fuzzy-matches every RESOLVER.md trigger phrase against the target skill's
frontmatter triggers list. pr-718 added six new RESOLVER routings without
declaring matching triggers:

- media-ingest: 'PDF book', 'summarize this book', 'ingest it into my brain'
- article-enrichment: 'enriching the article', 'enrich the article', 'enrich pass'
- concept-synthesis: 'canon vs riff'
- perplexity-research: 'perplexity-research', 'surface new developments'
- academic-verify: 'Retraction Watch'
- voice-note-ingest: 'audio message'

Adds the missing triggers verbatim to each skill's frontmatter so the
round-trip invariant holds.

* chore: regenerate llms.txt + llms-full.txt after wave skill updates

* v0.30.3 release: bump VERSION + CHANGELOG entry

22-PR community fix wave with one P0 security upgrade (auth-code scope
escalation closed). 19 PRs landed across 5 lanes; 3 superseded by master
during cherry-pick; 1 deferred per E2 protocol (#681 architectural
conflict with v0.28 takes-holders); follow-up filed.

Headline fixes: #727 (auth-code scope-clamp, RFC 6749 §3.3 compliance),
#740/#751 (v0.29.1 PGLite migration connect), #741 (v39-v41 forward-
reference bootstrap), #757 (multi-source sourceId threading, closes
Postgres 21000), #728 (takes-fence redaction on remote reads).

See CHANGELOG.md for full per-PR attribution and decision history.

Co-Authored-By: lanceretter <lance@csatlanta.com>
Co-Authored-By: alexandreroumieu-codeapprentice <agency.aubergine.code@gmail.com>
Co-Authored-By: brandonlipman <brandon@offdeck.com>
Co-Authored-By: gus <gustavoraularagon@gmail.com>
Co-Authored-By: jeremyknows <jeremyknows@protonmail.com>
Co-Authored-By: Trevin Chow <trevin@trevinchow.com>
Co-Authored-By: WD <wd@WDdeMacBook-Pro.local>
Co-Authored-By: Federico Cachero <federicocachero.tango@gmail.com>
Co-Authored-By: Brandon Lipman <brandon@offdeck.com>
Co-Authored-By: joshsteinvc <josh@stein.vc>
Co-Authored-By: mgunnin <michael.gunnin@gmail.com>
Co-Authored-By: NineClaws Brain <joel@5nine64.com>
Co-Authored-By: joelwp <joel.phillips@gmail.com>
Co-Authored-By: Oscar <oscar@Mac-mini-de-Oscar.local>

* test(C6): regression test for #745 collectChildPutPageSlugs

Codex-mandated test gate (C6 from /codex review of v0.30.3 plan).

Pins behavior of collectChildPutPageSlugs() under both jsonb shapes:
- jsonb_typeof='object' (post-#745, normal write path)
- jsonb_typeof='string' (pre-#745 double-encoded, the bug shape)

Without this guard, a future regression of #745 would silently drop slugs:
child jobs finish, queue looks healthy, orchestrator writes nothing.
Worst on-call shape — silent failure with no alerting surface.

Adds an `__testing` namespace to src/core/cycle/synthesize.ts re-exporting
collectChildPutPageSlugs at unit-test granularity. Not part of the runtime
contract; matches the v0_29_1.ts `__testing` precedent for engine-internal
helpers.

* test(C8): #708 .md transcript discovery + self-consumption guard

Codex-mandated test gate (C8 from /codex review of v0.30.3 plan).

Pins three invariants for #708's broadening of transcript discovery:

  1. .md files ARE discovered alongside .txt (the feature works).
  2. Other extensions (.pdf, .doc, .json) are still SKIPPED.
  3. v0.30.2's dream_generated frontmatter marker MUST guard .md files
     against self-consumption — without this, every dream cycle would
     loop on its own output indefinitely.

Adversarial cases: BOM + CRLF tolerance on .md frontmatter; the
--unsafe-bypass-dream-guard escape hatch for .md output; mixed .txt + .md
corpus dedup behavior pinned.

* test(C4): takes-fence redaction regression on get_page + get_versions

Codex-mandated test gate (C4 from /codex review of v0.30.3 plan).

Pins three privacy invariants for #728's fence-stripping in operations.ts:

  1. Local CLI caller (no allow-list) sees full takes fence — operator
     reads should preserve everything.
  2. MCP-bound caller (allow-list set) sees compiled_truth with fence
     STRIPPED on get_page AND get_versions.
  3. Allow-list PRESENCE (not contents) flags MCP-bound identity. Even
     a permissive ['world','garry','brain'] still strips, because the
     typed read surface for takes is takes_list / takes_search, not
     get_page or get_versions.

Lane 4 (#757 + #728) was the high-risk merge surface for this privacy
invariant. The test runs through dispatchToolCall to exercise the full
threading path (auth → context → handler → engine read → stripTakesFence)
so a future bad merge fails loudly at the conflict seam in operations.ts.

* test(C3): rewound-brain E2E for v39-v41 forward-reference bootstrap

Codex-mandated test gate (C3 from /codex review of v0.30.3 plan).

Pins the upgrade-path claim in the v0.30.3 release notes: brains stuck
at config.version < 39 (Postgres) or < 41 (PGLite) walk forward cleanly
through #741's bootstrap additions. Without this, the release note's
"old PGLite brains upgrade cleanly through v39-v41" was unproven.

Four cases:
  1. pre-v39 (missing modality + embedding_image)
  2. pre-v40 (missing emotional_weight + effective_date + effective_date_source)
  3. pre-v41 (missing import_filename + salience_touched_at)
  4. compounded pre-v34 wedge (v0.20 + v0.26.3 + v39-v41 all dropped at once)

Pattern follows test/e2e/v0_28_5-fix-wave.test.ts: build a fresh LATEST
brain, surgically rewind via DROP COLUMN CASCADE + UPDATE config.version,
then re-call initSchema and assert advancement to LATEST_VERSION with
the rewound columns restored. PGLite-only — Postgres-side bootstrap is
covered separately by test/e2e/postgres-bootstrap.test.ts.

* fix(test): rename migration-v0-29-1 to .serial.test.ts (CI lint)

CI's check-test-isolation lint flags the test for direct process.env.GBRAIN_HOME
mutation in beforeEach (rule R1: parallel-test-unsafe). The test is genuinely
env-coupled — it sets GBRAIN_HOME so loadConfig() inside the migration phases
finds the test fixture. Per CLAUDE.md ("When to quarantine instead of fix")
and the lint's own fix hint, env-coupled tests get renamed to *.serial.test.ts
to run in the serial bucket.

Verified: bash scripts/check-test-isolation.sh now reports OK; the renamed
test still runs green (1 pass / 0 fail, ~1.5s).

* fix(types): voyageCompatFetch — cast through unknown for Bun typeof fetch

CI's tsc --noEmit failed:
  src/core/ai/gateway.ts(249,7): error TS2741: Property 'preconnect' is
  missing in type '(input: RequestInfo | URL, init: RequestInit | ...) =>
  Promise<Response>' but required in type 'typeof fetch'.

Bun's @types/bun extends the standard fetch type with a preconnect method
that arrow functions can't satisfy. The AI SDK only invokes the call
signature; the Bun extension surface is irrelevant to voyageCompatFetch's
behavior.

Cast through `unknown` (TS2352-safe pattern for cross-type-family casts)
with explicit param types on the arrow function. Comment names the exact
TS2741 the cast suppresses so a future maintainer can audit the choice.

Companion to #735 (Voyage encoding-format adapter) — the original PR
introduced voyageCompatFetch typed against typeof fetch; the wave-side
typecheck error was caught by CI on the assembled branch.

* fix(test/e2e): rename + update dream-cycle phase-order test

The test file said "v0.23 8-phase cycle" but ALL_PHASES has been 9
since v0.26.5 (added `purge`) and 10 since v0.29 (added
`recompute_emotional_weight` between patterns and embed). The
hardcoded 8-element array assertion was stale documentation.

Renamed the file from dream-cycle-eight-phase-pglite.test.ts to
dream-cycle-phase-order-pglite.test.ts to make the maintenance
contract explicit: this test pins the canonical phase sequence,
whatever its current length, against unintended reorderings or
removals.

Extracted EXPECTED_PHASES as a typed const so the assertion lives in
one place and TypeScript's CyclePhase narrowing catches typos in the
phase names.

* fix(test/e2e): cycle.test.ts expects 10 phases (v0.29 added recompute_emotional_weight)

Same root cause as dream-cycle-phase-order-pglite.test.ts: hardcoded
phase count assertion drifted behind ALL_PHASES growth.

Phase history:
  v0.23  = 8 phases
  v0.26.5 = 9 (added `purge` last)
  v0.29  = 10 (added `recompute_emotional_weight` between patterns and embed)

* fix(test/e2e): scope GBRAIN_HOME to tmpdir for Doctor Command tests

`gbrain doctor`'s minions_migration check reads
`~/.gbrain/migrations/completed.jsonl` to detect half-installed
migrations. Pre-fix the test inherited the developer's local
$HOME, so stale partial entries from in-flight workspaces (e.g.
v0.31.0 in santiago) made the check fail and the test exit 1 —
masking real DB-health failures.

Added per-describe-block `gbrainHome` tmpdir, threaded through
`cliEnv()` so all spawned gbrain CLI calls in this block read a
hermetic, empty migrations ledger. Cleanup in afterAll.

* fix(claw-test): pass --dir explicitly to extract phase (companion to #688)

Pre-#688 `gbrain extract` defaulted to cwd. Post-#688 it requires
either a configured fs source or explicit --dir, otherwise it errors
out: "No brain directory configured."

The claw-test scripted scenarios run `gbrain init --pglite` in their
install_brain phase, which doesn't register a fs source. So the
extract phase needs --dir <brainDir> explicitly. Skip the extract
phase entirely when the scenario has no brain dir.

Captured brainDir at the import-phase site so it's reusable by extract.

* fix(preferences): route migration ledger paths through gbrainPath()

Pre-fix, preferences.ts used `$HOME/.gbrain` directly via its own
`home()` helper. Tests that set `process.env.HOME = tmpdir`
expecting hermetic isolation worked — but tests that set
`GBRAIN_HOME = tmpdir` (the documented override per
`src/core/config.ts`) didn't, because preferences ignored it.

Routed prefsDir(), prefsPath(), migrationsDir(), and
completedJsonlPath() through gbrainPath() (which honors
GBRAIN_HOME, falling back to homedir() when unset). The legacy
home() helper stays for any future code path that wants $HOME
specifically.

Updated three tests that mutated process.env.HOME to also mutate
GBRAIN_HOME so the same test body works against the new contract:
test/preferences.test.ts, test/migration-resume.test.ts,
test/e2e/migration-flow.test.ts.

* release: rename version slot to 0.31.1.1-fixwave

Originally bumped to 0.31.2 during the master merge to stay strictly
monotonic. Garry called the slot back to `0.31.1.1-fixwave` to
communicate intent: this is a fix wave on top of v0.31.1, not a new
minor or patch slot. The next regular release slot (v0.31.2) stays
free for in-flight feature work.

Format check:
- bun install accepts the literal version (verified)
- compareVersions() in src/commands/migrations/index.ts splits on
  '.' and parseInt's each segment, taking only the first 3. So
  '0.31.1.1-fixwave' compares as [0,31,1] = equal to '0.31.1' for
  migration-ordering purposes. Wave has no new schema migrations,
  so equality is fine.
- Compares stable to 0.31.1 in the migration runner; later versions
  (0.31.2, 0.32.x, etc.) sort strictly above as normal.

Updated:
- VERSION
- package.json (with bun.lock refresh)
- CHANGELOG.md entry header + 'To take advantage of' block + 'For
  contributors' reference
- llms.txt + llms-full.txt regenerated to match

---------

Co-authored-by: lanceretter <lance@csatlanta.com>
Co-authored-by: Oscar <oscar@Mac-mini-de-Oscar.local>
Co-authored-by: WD <wd@WDdeMacBook-Pro.local>
Co-authored-by: gus <gustavoraularagon@gmail.com>
Co-authored-by: Trevin Chow <trevin@trevinchow.com>
Co-authored-by: Brandon Lipman <brandon@offdeck.com>
Co-authored-by: Federico Cachero <federicocachero.tango@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Josh Stein <josh@threshold.vc>
Co-authored-by: Matt Gunnin <mgunnin@esports.one>
Co-authored-by: Michael Dela Cruz <adobobro@mac.lan>
Co-authored-by: Jeremy Knows <jeremy@veefriends.com>
Co-authored-by: joelwp <joel.phillips@gmail.com>
Co-authored-by: NineClaws Brain <joel@5nine64.com>
Co-authored-by: alexandreroumieu-codeapprentice <agency.aubergine.code@gmail.com>
Co-authored-by: jeremyknows <jeremyknows@protonmail.com>
Co-authored-by: joshsteinvc <josh@stein.vc>
Co-authored-by: mgunnin <michael.gunnin@gmail.com>
2026-05-09 20:46:34 -07:00
b2fd26482e v0.31.1 feat: thin-client mode actually works (Issue #734) (#772)
* v0.31.1 feat: get_brain_identity MCP op (Issue #734 prep)

Lightweight read-scope op that returns {version, engine, page_count,
chunk_count, last_sync_iso} for the thin-client identity banner.
Reuses engine.getStats() — banner's 60s TTL cache (next commit) bounds
frequency to ≤1/60s per CLI process. Banner-only op, no cliHints.

Pinned by 9 tests in test/get-brain-identity.test.ts.

Part of v0.31.1 fix for #734 (thin-client mode silently routing
~25 CLI commands to empty local PGLite). See plan at
~/.claude/plans/how-to-make-mcp-iterative-liskov.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* v0.31.1 feat: harden callRemoteTool error normalization + abort/timeout

CDX-4 (Codex outside-voice finding): the previous callRemoteTool let
plain Error escape — undici network errors, AbortError, JSON parse
failures all bubbled untyped. Plan called for an exhaustive switch on
RemoteMcpError.reason at the dispatcher; that contract was unsound.

Hardening:
- New CallRemoteToolOptions {timeoutMs?, signal?} (4th arg, optional).
- buildAbortController composes external signal with timeout into a
  single signal threaded through the SDK transport's requestInit.
- toRemoteMcpError funnel converts ANY thrown value to RemoteMcpError
  before re-raising; the outermost try/catch guarantees the contract.
- RemoteMcpErrorReason exported as a stable union type.
- RemoteMcpErrorDetail.kind ('timeout'|'aborted'|'unreachable') sub-tags
  network errors so the dispatcher can render the right hint.
- RemoteMcpErrorDetail.code carries server-supplied error codes on
  tool_error (e.g. 'missing_scope') for pinpoint refusal hints.
- extractToolErrorCode parses JSON envelopes first, falls back to
  substring detection for legacy server messages.

All 13 existing mcp-client tests still pass. Typecheck clean.

Part of v0.31.1 fix for #734.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* v0.31.1 feat: --timeout=Ns CLI flag for thin-client routed calls (ENG-4)

New global flag --timeout that accepts ms / s / m / ms-suffix forms
("30s", "2m", "500ms", "500"). Default null = per-command default
(30s for most ops, 180s for `think` per ENG-4). Plumbs through to
callRemoteTool's AbortController via cliOpts.timeoutMs.

Rejection cases (timeoutMs stays null, flag falls through):
- --timeout=0 (must be positive)
- --timeout=garbage (no parse)

Pinned by 8 new tests in test/cli-options.test.ts (total 28 pass).

Part of v0.31.1 fix for #734.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* v0.31.1 feat: thin-client routing seam in cli.ts (CDX-1)

The keystone fix for Issue #734. Inserts the routing seam INSIDE the
existing op-dispatch path in cli.ts:78-138 (per Codex finding CDX-1) —
no parallel `src/core/thin-client/` module. Routing is a ~80-line
conditional that runs BEFORE connectEngine() so thin-client installs
never open the empty local PGLite.

Architecture (CDX-1, CDX-4, ENG-2, ENG-4):
- Existing arg parser, image-to-base64 transform, stdin handler, and
  required-param check run UNCHANGED before the routing branch. Zero
  duplicated parsers.
- New runThinClientRouted(op, params, cfg, cliOpts) calls callRemoteTool
  with {timeoutMs, signal}; default 180s for `think`, 30s otherwise;
  --timeout flag overrides.
- SIGINT abort threaded into AbortController → exit 130.
- Exhaustive TS `never` switch on RemoteMcpError.reason produces canned,
  actionable user messages per failure mode (ENG-4 contract).
- ENG-2 renderer parity: local-engine path runs JSON.parse(JSON.stringify())
  on the result before formatResult, killing the Date/bigint/Buffer drift
  class without per-command renderer audit.
- THIN_CLIENT_REFUSE_HINTS table replaces the generic refusal message
  with pinpoint hints (CDX-5 / cherry-pick A). Adds dream/transcripts/storage
  to the refused set with their own hints.
- localOnly ops on thin-client refuse via refuseThinClient (with hint).

Pinned by 14 cli-dispatch-thin-client tests (all pass). Typecheck clean.

Part of v0.31.1 fix for #734.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* v0.31.1 feat: thin-client identity banner (cherry-pick B)

Prints "[thin-client → wintermute.fly.dev:3131 · brain: 102k pages,
265k chunks · v0.31.1]" to stderr before each routed command. Kills the
"am I empty?" confusion that drove the original Hermes/Neuromancer
report against wintermute (102k pages → empty CLI search results).

Cache: 60s TTL, in-memory Map keyed by mcp_url so switching hosts via
`gbrain init` invalidates cleanly. Cross-process file cache deferred.

Suppression: --quiet, GBRAIN_NO_BANNER=1, non-TTY default suppresses
unless GBRAIN_BANNER=1 explicitly opts in (clean pipes for shell flows).

Failure mode: banner fetch errors swallowed; underlying command runs
normally. Banner is observability, never load-bearing. The hardened
callRemoteTool will surface the same error class on the actual call
if the host is genuinely unreachable.

Inline in cli.ts per CDX-1 (no parallel module). _clearIdentityCacheForTest
exported as test escape hatch.

Backed by the new `get_brain_identity` MCP op (read-scope, banner-only).

Part of v0.31.1 fix for #734.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* v0.31.1 feat: route CLI-only commands with MCP equivalents (salience/anomalies/graph-query/think)

These four CLI commands bypass the operation-layer dispatch and call
engine methods directly today, so the cli.ts routing seam doesn't catch
them. Each gets a thin per-command branch: when isThinClient(cfg),
callRemoteTool against the corresponding op; otherwise existing engine
path runs unchanged.

Mappings:
- gbrain salience    → get_recent_salience  (read scope, 30s timeout)
- gbrain anomalies   → find_anomalies       (read scope, 30s timeout)
- gbrain graph-query → traverse_graph       (read scope, 30s timeout)
- gbrain think       → think                (write scope, 180s timeout)

`think` is a special case: the server's think op intentionally disables
--save/--take for remote callers (operations.ts:1103-1135 trust-boundary
gate per CLAUDE.md subagent-isolation policy). Thin-client think prints a
loud warning when those flags are set so users know what they lose
instead of silent ignoring. Documented as v0.31.x policy review in plan.

Output format unchanged on both paths — the MCP op handler IS the engine
method, so the unpacked tool result has identical shape.

Part of v0.31.1 fix for #734.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* v0.31.1 feat: oauth_client_scopes_probe doctor check (CDX-5)

\`gbrain remote doctor\` gains a 5th check that probes the read + admin
scope tiers via two harmless read-only MCP calls (get_brain_identity
and get_health). Surfaces v0.29.2/v0.30.0 thin-client clients that
registered with read+write only and now hit \`gbrain stats\` /
\`gbrain history\` and fail mid-flight — instead of failing
mid-command, doctor names the exact remediation:

  On the host: gbrain auth register-client <name> --grant-types
    client_credentials --scopes read,write,admin

Status semantics (informational by default):
- read.missing_scope  → fail (broken setup)
- admin.missing_scope → warn + pinpoint hint (the load-bearing case)
- both succeed        → ok
- non-scope probe errors (parse/network/timeout) → ok with
  detail.inconclusive=true (doctor's overall status doesn't flap)

GBRAIN_DOCTOR_SKIP_SCOPE_PROBE=1 env-flag for test fixtures that mock
/mcp at JSON-RPC initialize level only (MCP SDK Client hangs on shape
mismatch and doesn't always honor AbortSignal — adversarial test
behavior we don't want to bake into doctor).

Pinned by 8 cases in test/oauth-scope-probe.test.ts (pure-function
buildScopeCheck) plus unchanged passing of all 23 doctor-remote tests.

CDX-5 from the codex outside-voice review. Keeps host-side
\`gbrain auth register-client\` default at \`read\` (no breaking change
for existing scrapers); puts the migration burden on the THIN-CLIENT
side where it belongs.

Part of v0.31.1 fix for #734.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* v0.31.1 feat: refuse \`takes\`/\`sources\` on thin-client with MCP-tool hints (CDX-2)

Per the CDX-2 op-coverage audit: takes and sources are multi-subcommand
CLIs with mixed local/routable surface. Their READ subcommands
(takes_list, takes_search, sources_list, sources_status) have MCP
equivalents — those land in v0.31.x with per-subcommand splits.

For v0.31.1, refuse both at the top level with hints naming the MCP
tools so agents know exactly which tools to invoke directly. Honest
framing per CDX-2: "thin-client gbrain routes the read+write+admin op
surface; multi-subcommand CLIs land incrementally."

Per-subcommand routing recorded as v0.31.x TODO in the plan.

Storage is also refused (filesystem-bound; no remote equivalent).

Part of v0.31.1 fix for #734.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* v0.31.1 docs + version: bump VERSION/package.json, CHANGELOG, TODOS, CLAUDE.md

Cross-cut for v0.31.1 ship:
- VERSION: 0.30.0 → 0.31.1
- package.json: "version": "0.31.1" (bun install refreshed bun.lock)
- CHANGELOG.md: full release-summary entry per CLAUDE.md voice contract
  (numbers-that-matter table with before/after comparison, what-this-means
  closer, take-advantage block with exact remediation commands, itemized
  changes by surface, contributor section with plan/decision-history pointer)
- TODOS.md: 7 follow-up entries for v0.31.x (timing telemetry, job-routing,
  per-subcommand takes/sources split, transcripts privacy decision,
  trust-boundary policy review, register-client default flip, cross-process
  token cache, parity test backfill)
- CLAUDE.md: new "Thin-client routing" section under "Key files" annotating
  every changed/new file with its v0.31.1 contract — src/cli.ts routing
  seam, src/core/mcp-client.ts hardening, src/core/cli-options.ts --timeout,
  src/core/doctor-remote.ts scope-probe, get_brain_identity op, per-command
  routing in salience/anomalies/graph-query/think.

Part of v0.31.1 fix for #734.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* v0.31.1 fix: collectRemoteDoctorReport opts.skipScopeProbe + regen llms.txt

Replaces the env-var GBRAIN_DOCTOR_SKIP_SCOPE_PROBE module-mutation in
test/doctor-remote.test.ts with an explicit opts arg threaded through
collectRemoteDoctorReport(config, opts). Satisfies the test-isolation
lint (rule R1: no process.env.X = ... in non-serial unit files).

Production callers still honor the env-flag for ops bypass; opts wins
when both are set.

Also regenerates llms.txt + llms-full.txt to match the v0.31.1 CLAUDE.md
additions (build:llms drift check passes).

Part of v0.31.1 fix for #734.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* v0.31.1 test: close coverage gaps — issue #734 e2e regression + CDX-4 hardening unit tests

Two real gaps the prior coverage missed:

1. **Issue #734 regression e2e** (test/e2e/thin-client.test.ts +6 cases):
   Existing e2e covered init/doctor/sync-refusal/remote-ping/no-admin but
   never exercised the actual bug — `gbrain search` against a populated
   host. Added the load-bearing regression: seed two pages on the host,
   run thin-client `gbrain search "<unique-token>"`, assert non-zero rows
   AND seeded slug present in stdout. If this assertion ever fails, #734
   has regressed.

   Plus: routed identity banner verification (GBRAIN_BANNER=1 path),
   --quiet suppression check, routed put round-trip (write reaches host,
   visible from host's local engine), routed admin stats (page_count > 0
   not 0/0), and pinpoint refuse-hint format for `gbrain sync`.

2. **CDX-4 hardening unit tests** (test/mcp-client-hardening.test.ts +31
   cases): pre-fix the hardening pass had ZERO direct unit coverage. The
   "exhaustive switch on RemoteMcpError.reason" promise depended on
   toRemoteMcpError actually normalizing every thrown value, but nothing
   verified that contract. Added:
   - toRemoteMcpError: passthrough for RemoteMcpError, AbortError →
     network/aborted, plain Error → network/unreachable, string/object/null
     non-Error throwables → network/unreachable, mcp_url always populated,
     contract test that EVERY output has a recognized reason
   - extractToolErrorCode: JSON envelope (error.code + top-level code),
     substring fallback for missing-scope-shaped messages, defensive
     handling of non-string code field, malformed-JSON fallthrough
   - buildAbortController: timeout fires on schedule, external signal
     propagates immediately when pre-aborted and lazily when aborted later,
     timeout + external compose (whichever fires first wins), cleanup is
     idempotent and removes external listener (no leak)
   - RemoteMcpError class shape (instanceof Error, reason/detail readonly,
     name="RemoteMcpError", detail optional)
   - CallRemoteToolOptions type contract

Internal helpers (toRemoteMcpError, extractToolErrorCode,
buildAbortController) gain @internal export tags so the test file can
import them without going through the SDK transport.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* v0.31.1 test: move routing tests before remote-ping; fix pre-existing assertion for new refusal format

The newly-added routing tests were running AFTER `gbrain remote ping`,
which submits a 60s autopilot-cycle and can leave the server in a
state where subsequent OAuth probes fail. Moving them before Tier B
so they exercise a healthy server.

Also updated the existing `sync is refused with canonical thin-client error`
test assertion: v0.31.1 changed the refusal format from generic
\`thin client\` (with space) to the pinpoint \`thin-client of <url>\`
(with hyphen) plus \`not routable\` prefix. The test now asserts both
the new format and the pinpoint hint.

E2E result: 10 pass / 3 fail. The 3 failures are pre-existing on master
(remote-ping timeout, client-without-admin OAuth discovery flake) and
not in my diff scope.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* v0.31.1 fix: scrub banned fork name from new test fixtures (CI privacy gate)

CI's check-privacy.sh rejected the v0.31.1 test additions because the
unique-token fixture string used the private OpenClaw fork name as a
prefix. Replaced with neutral names per CLAUDE.md privacy rule:

- test/e2e/thin-client.test.ts: \`wintermute_routing_proof\` →
  \`host_routing_proof\` (the unique-token marker that proves search
  results came from the remote brain, not the empty local PGLite).
  All 6 references updated.

- test/mcp-client-hardening.test.ts: \`https://wintermute.fly.dev/mcp\` →
  \`https://brain-host.example/mcp\` (the synthetic MCP URL used as the
  toRemoteMcpError second arg). Matches the convention used in the
  existing test/cli-dispatch-thin-client.test.ts fixture.

bun run verify passes; 31/31 hardening tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 19:47:56 -07:00
8392d434a9 v0.29.2 feat: thin-client mode (gbrain init --mcp-only + gbrain remote ping/doctor + topologies) (#732)
* feat(config): add remote_mcp field + isThinClient() helper

Adds a top-level optional remote_mcp config block to GBrainConfig
(issuer_url, mcp_url, oauth_client_id, oauth_client_secret) for
thin-client installs that consume a remote `gbrain serve --http` over
MCP instead of running a local engine.

isThinClient(config) returns true when remote_mcp is set; used by the
CLI dispatch guard, doctor branch, and init re-run guard. The engine
field stays as today (postgres|pglite); thin-client mode is a separate
config field, NOT an engine kind extension (codex outside-voice review
flagged the engine='remote' extension as overreach).

GBRAIN_REMOTE_CLIENT_SECRET env var overrides the config-file value at
load time so the secret can stay out of disk for headless agents.

Foundation commit for multi-topology v1; no behavior change yet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(probe): outbound OAuth + MCP smoke probes

Adds three pure async functions over the standard fetch API:
  - discoverOAuth(issuerUrl): GET /.well-known/oauth-authorization-server
  - mintClientCredentialsToken(tokenEndpoint, id, secret): POST /token
  - smokeTestMcp(mcpUrl, accessToken): POST /mcp initialize

Discriminated 'ok=true' / 'ok=false + reason' return shapes so callers
render error messages consistently. No SDK dependency to keep init's
setup-flow scope tight; Lane B's mcp-client.ts will pull in the
official @modelcontextprotocol/sdk Client for full session semantics.

Used by both 'gbrain init --mcp-only' (Lane A's setup smoke) and
runRemoteDoctor (Lane A's thin-client doctor checks).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(init): --mcp-only branch + re-run guard

Adds 'gbrain init --mcp-only' for thin-client setup. Required flags
(or env vars):
  --issuer-url     OAuth root (e.g. https://host:3001)
  --mcp-url        MCP tool dispatch path (e.g. https://host:3001/mcp)
  --oauth-client-id, --oauth-client-secret

Pre-flight runs three smoke probes (discovery, token round-trip, MCP
initialize) BEFORE writing the config — fail-fast on bad URL beats
fail-late on bad credentials. On success, writes ~/.gbrain/config.json
with remote_mcp set and NO local DB created.

Re-run guard (A8): when ~/.gbrain/config.json already has remote_mcp,
'gbrain init' (any flag set) refuses without --force. Catches the
scripted-setup-loop friction from the user-reported scenario where
re-running setup-gbrain on a thin-client machine kept trying to
re-create a local DB.

Two URLs in config (issuer + mcp) instead of one because OAuth
discovery + /token live at the issuer root while tool dispatch is at
/mcp — they compose from a common base in practice but reverse-proxy
setups need them explicit (codex review #2).

Tests: 15 cases covering happy path, env-var-supplied secret stays
out of disk, all four required-flag missing-error paths, three
smoke-failure paths, network-unreachable path, and the four re-run
guard variants (default/--pglite/--mcp-only without --force / with
--force). Uses async Bun.spawn (NOT execFileSync) — sync exec
deadlocks against in-process HTTP fixtures because the parent's
event loop can't accept connections while sync-blocked on a child.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(doctor): runRemoteDoctor for thin-client mode

Replaces every DB-bound check from runDoctor() with a tighter set
scoped to 'is the remote MCP we configured actually reachable?'.
Five checks:
  - config_integrity (URL fields well-formed)
  - oauth_credentials (secret resolvable from env or config file)
  - oauth_discovery (GET /.well-known/oauth-authorization-server)
  - oauth_token (POST /token client_credentials)
  - mcp_smoke (POST /mcp initialize)

Output shape matches the local doctor's Check surface so JSON
consumers can union the two without conditional logic. schema_version
is 2 (matches local doctor).

collectRemoteDoctorReport() is the pure data collector;
runRemoteDoctor() is the print/exit wrapper. Tests pin the data
collector so we don't have to intercept stdout / process.exit.

Tests: 12 cases over a tiny in-process HTTP fixture covering happy
path, every probe failure mode (404/parse/auth/network/server-error),
malformed-URL config integrity, missing-secret short-circuit, and
the env-var-overrides-config-file secret resolution. withEnv() helper
used for env mutations to satisfy the test-isolation lint.

Module is added but not yet wired into the CLI doctor branch; the
wiring lands in the next commit (cli dispatch guard + doctor routing).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(cli): thin-client dispatch guard + doctor routing

Adds a single canonical refusal at the top of handleCliOnly() for the
9 DB-bound commands when ~/.gbrain/config.json has remote_mcp set:
  sync, embed, extract, migrate, apply-migrations, repair-jsonb,
  orphans, integrity, serve

Single dispatch check (not 9 sprinkled assertLocalEngine calls per
codex review #1) — avoids the blast radius of letting commands enter
connectEngine before the check fires. Refused commands exit 1 with a
canonical error naming the remote mcp_url.

doctor branch routes to runRemoteDoctor when isThinClient(config)
returns true; falls through to the existing local-doctor flow
otherwise. Wires the module added in the previous commit into the
user-facing CLI surface.

Safe commands (init, auth, --version, --help, etc.) still work in
thin-client mode and are NOT in the refused set.

Tests: 14 cases — 9 refused commands × 1 each, 2 safe commands, 1
doctor-routing assertion (fingerprints the thin-client output by
'mode:"thin-client"' in JSON), 2 regression tests asserting local
config still passes through normally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(topologies): multi-topology architecture guide + setup skill Phase A.5

New docs/architecture/topologies.md covering three deployment shapes:
  1. Single brain (today's default)
  2. Cross-machine thin client (consume a remote brain over MCP)
  3. Split-engine per-worktree (Conductor users with per-worktree
     code engines + shared remote artifacts brain)

Each topology gets an ASCII diagram, when-it-fits guidance, and
concrete setup recipes. Topology 3's alias-level routing footgun
(wrong alias = silent wrong-brain writes) is called out explicitly
per codex review #6.

Topology 3 needs zero gbrain code changes — GBRAIN_HOME already
overrides ~/.gbrain and 'gbrain serve --http --port N' already runs
on any port. gstack composes these primitives on its side.

skills/setup/SKILL.md gets Phase A.5 BEFORE the local-engine phases.
Asks the user which topology fits, walks thin-client setup through
'gbrain init --mcp-only', skips Phases B/C/C.5/H entirely for thin
clients (host's autopilot handles sync/extract/embed).

README.md gets a one-line link to the topology doc from the
Architecture section.

llms-full.txt regenerated to include the new doc.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(e2e): thin-client end-to-end skeleton

Spins up 'gbrain serve --http' against real Postgres, registers a
client with read,write,admin scope, runs 'gbrain init --mcp-only'
from a separate tempdir GBRAIN_HOME, exercises the canonical
thin-client flows:

  - init --mcp-only succeeds against the live host
  - doctor reports mode: thin-client + all checks green
  - sync is refused with the canonical thin-client error
  - re-running init refuses without --force

Tier B flows (gbrain remote ping / doctor) will be added alongside
their Lane B implementation. Skips when DATABASE_URL unset (matches
the e2e gate convention used across the suite).

Async Bun.spawn (NOT execFileSync) so the test event loop stays
responsive — execFileSync deadlocks against in-process HTTP fixtures
because the parent's event loop can't accept connections while
sync-blocked on a child process.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(doctor): doctorReportRemote core for thin-client + run_doctor op

Adds three new exports to src/commands/doctor.ts that the run_doctor MCP
op + gbrain remote doctor CLI both consume:

  - DoctorReport interface       schema_version=2 stable shape
  - computeDoctorReport(checks)  status + health_score math
  - doctorReportRemote(engine)   focused 5-check thin-client surface

doctorReportRemote runs:
  1. connection      (engine reachable + page count via getStats)
  2. schema_version  (engine.getConfig('version') vs LATEST_VERSION)
  3. brain_score     (the 5-component composite)
  4. sync_failures   (file-plane JSONL count from gbrainPath('sync-failures.jsonl'))
  5. queue_health    (Postgres-only: stalled active jobs > 1h)

Engine-agnostic: works on both Postgres and PGLite via engine.executeRaw +
engine.getConfig + engine.getHealth — no reliance on db.getConnection()
which is Postgres-only.

Deliberately a focused subset of the local doctor surface, NOT a full
mirror. Generalizing to lint/integrity/orphans is filed as follow-up
pending demand. Local doctor (runDoctor) is unchanged; operators on the
host machine still get the full check set.

schema_version=2 matches the local doctor's --json output schema, so JSON
consumers can union the two without conditional logic.

Tests: 11 unit cases against PGLite covering the 5-check happy path,
schema version reporting (latest), PGLite-specific queue_health
informational message, and the score+status math via computeDoctorReport.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(mcp-client): outbound HTTP MCP client over @modelcontextprotocol/sdk

New src/core/mcp-client.ts wraps the official SDK's Client +
StreamableHTTPClientTransport with OAuth client_credentials minting,
in-process token caching with expires_at, and refresh-on-401 retry.

Public surface:
  - callRemoteTool(config, toolName, args)   tool call w/ auto-refresh
  - unpackToolResult(res)                    parse content[0].text JSON
  - RemoteMcpError                           discriminated by `reason`

Token cache: module-level Map keyed by mcp_url. CLI processes are
short-lived; the cache amortizes when one invocation makes multiple
calls (gbrain remote ping submits then polls). Persisting to disk would
be a credential-on-disk surface for marginal benefit since /token
round-trip is sub-100ms.

401 retry: ONLY for mid-session token rotation (initial good token →
stale → 401). If the FIRST mint fails auth, surface immediately as
RemoteMcpError(auth) — retry won't help when credentials are wrong from
the start. If a fresh-mint-after-401 still 401s, surface as
RemoteMcpError(auth_after_refresh) which the CLI renders with a hint
pointing the operator at gbrain auth register-client.

Used by gbrain remote ping (submit_job + get_job poll) and gbrain
remote doctor (run_doctor). Test-only _clearMcpClientTokenCache export
for fixture isolation.

Tests: 13 unit cases over an in-process HTTP fixture mimicking gbrain
serve --http (OAuth discovery + /token + /mcp JSON-RPC handshake).
Covers happy path, token cache reuse + force-refresh, args passthrough,
config-error paths (no remote_mcp / no secret), token mint 401, network
unreachable, tool isError envelope, and unpackToolResult parse failures.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(operations): add run_doctor MCP op (admin scope, HTTP-reachable)

New op in src/core/operations.ts wraps doctorReportRemote() and returns
the structured DoctorReport JSON over MCP.

  scope:     'admin'       (system-state read; not for routine consumers)
  localOnly: false         (reachable over HTTP)
  mutating:  false         (safe to call repeatedly)
  params:    {}            (no caller arguments needed)

First read-only diagnostic op exposed over HTTP MCP. Used by gbrain
remote doctor — the matching client-side renderer lives in
src/commands/remote.ts.

Precedent: doctor only. Generalizing run_lint / run_integrity /
run_orphans to MCP is filed as follow-up work pending demand. Local
doctor stays unchanged; this op is the operator-friendly subset for
remote callers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(remote): gbrain remote ping + gbrain remote doctor

Two thin-client convenience commands that round-trip through the host's
HTTP MCP endpoint:

  - gbrain remote ping     submit_job(autopilot-cycle) → poll get_job →
                           exit when terminal. The "I just wrote markdown,
                           tell the host to re-index" affordance.
  - gbrain remote doctor   run_doctor MCP op → render the host's
                           DoctorReport → exit 0/1 based on status.

Both require a thin-client install (~/.gbrain/config.json with
remote_mcp). Local installs get a clear error pointing at the local
equivalents.

Polling backoff (ping): 1s × 30s, then 5s × 5min, then 10s. Default cap
15min, configurable via `--timeout`. Without backoff, a 5-min cycle
would burn 300 round-trips against the host's rate limiter.

Payload uses `data: {phases: [...]}`, NOT `params:` — the submit_job op
shape takes `data`. Codex review #8 catch.

NO `repo` arg passed to autopilot-cycle — uses the server's configured
brain repo. This sidesteps TODO #1144 (sync_brain repo-path validation
for caller-controlled paths) entirely.

src/cli.ts wires the `remote` subcommand into CLI_ONLY + the dispatch.
Help (`gbrain remote --help`) and unknown-subcommand handling included.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(e2e): thin-client Tier B + scope-mismatch regression

Extends the existing test/e2e/thin-client.test.ts with three new cases:

  1. gbrain remote doctor returns the host's DoctorReport — pins the
     run_doctor MCP op round-trip. Asserts schema_version=2, all 5
     check names present, connection + schema_version ok against a
     fresh host.
  2. gbrain remote ping triggers autopilot-cycle and returns terminal
     state — pins the submit_job → poll → terminal wire path. Accepts
     any terminal state (success / failed / dead / cancelled / timeout)
     because autopilot on an empty no-repo brain may fail-fast in the
     sync phase. What this test pins is the JSON shape (job_id present,
     state populated), NOT cycle success on a no-repo fixture.
  3. read+write client cannot call run_doctor — codex review #7
     regression guard. Registers a separate client with
     `--scopes "read write"` (no admin), runs `gbrain remote doctor`
     against it, asserts exit 1 with auth/auth_after_refresh/tool_error
     reason. Keeps the verification flow honest: the canonical setup
     MUST require admin scope.

`gbrain auth register-client` doesn't have --json, so the test parses
the human output for "Client ID:" and "Client Secret:" lines via a
helper.

Test-level timeout bumped 60s → 120s for the ping wait + auth/init
overhead.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: bump version and changelog (v0.29.2)

v0.29.2 ships thin-client mode: gbrain init --mcp-only, gbrain remote
ping/doctor, run_doctor MCP op, and the docs/architecture/topologies.md
deployment guide.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 22:56:22 -07:00