Commit Graph
3 Commits
Author SHA1 Message Date
83c4ca0564 v0.39.0.0 feat: brainstorm cost cathedral (P1-P7) + page_links schema fix (#1283)
* feat(brainstorm): T1 cost guardrails + judge chunking + far-set cap

Ports PR #1234 with a typed-error swap (Q2). Brings:

- `--max-cost`, `--max-far-set`, `--strict-budget`, `--judge-model`,
  `--max-ideas-per-judge-call` CLI flags on `gbrain brainstorm` / `lsd`
- Domain-bank prefix-cap + shuffle + final-trim to `m` by distance score
- Judge auto-chunks idea sets > 100 across multiple LLM calls
- UTF-16 surrogate sanitization on cross prompts
- Phase-0.5 hard cost ceiling + mid-run cost guard

Phase-1 diff from PR #1234: per-cross error-rethrow uses inline typed
`BudgetExhausted` instead of string-match on the error message. Phase 2
of the wave will move the class to `src/core/budget/budget-tracker.ts`
and the orchestrator will import it.

Postmortem doc + 12-case regression test included verbatim from #1234.

T1 of the brainstorm cost cathedral plan
(~/.claude/plans/system-instruction-you-are-working-rippling-moth.md).

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

* feat(budget): T2 BudgetTracker + BudgetExhausted + audit-week helper

The keystone primitive for the v0.37.x budget cathedral. One class,
one typed error, one schema-stable audit JSONL. Replaces three parallel
copies (brainstorm orchestrator inline class, cycle/budget-meter,
eval-contradictions cost-prompt/tracker) — those adapt to this one in
T5/T6.

Contracts pinned by 26 unit tests:
  - TX1: record() throws BudgetExhausted(reason:'cost') when cumulative
    spend > cap. A single underestimated call cannot leak past the cap.
  - TX2: reserve() hard-fails with BudgetExhausted(reason:'no_pricing')
    when cap is set + model is missing from pricing maps. When cap is
    unset, legacy warn-once behavior is preserved.
  - A3 amended: extractUsageFromError(err, fallback) returns err.usage
    when SDK provides it, else the pessimistic fallback (caller passes
    maxOutputTokens, not the optimistic pre-call estimate).
  - onExhausted callback fires once, synchronously, before the throw
    propagates. Callbacks do sync I/O (writeFileSync) for checkpoint
    persistence.
  - Audit JSONL is schema-stable: every line carries schema_version=1.
    Reorderings tolerated, field renames are breaking.

Also ships src/core/audit-week-file.ts — the shared ISO-week filename
helper consumed by every audit writer in T4. Year-boundary correctness
pinned by 5 cases including 2020-W53 (the 53-week year), 2025-W01
rolling in from 2024-12-30 (Monday), and the GBRAIN_AUDIT_DIR override.

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

* feat(gateway): T3 withBudgetTracker + AsyncLocalStorage composition

TX5: every gateway.chat / embed / rerank call now auto-composes the
active BudgetTracker via a module-internal AsyncLocalStorage. No
per-call injection seam, no flag plumbing — callers wrap their
entrypoint in `withBudgetTracker(tracker, async () => { ... })` and
every downstream LLM call honors the cap.

Outside any scope, the gateway is a budget no-op (back-compat with the
pre-v0.37 contract).

Wiring:
  - chat(): reserves on entry using prompt-char heuristic + opts.maxTokens.
    Records actual usage from result.usage on success; on failure, charges
    the pessimistic A3-amended fallback so the cap is real.
  - embed(): reserves total estimated input tokens (chars / chars-per-token).
    Records the same total in try/finally; SDK doesn't surface per-batch
    embed token counts.
  - rerank(): reserves and records query + docs char count.
    Reranker pricing isn't in the canonical map yet, so reserve() takes
    the warn-once path under no-cap and the TX2 hard-fail under cap.

6 unit cases pin the contract: chat auto-composes, outside-scope is
no-op, nested scope restores outer, over-cap reserve throws BEFORE
provider call (proves circuit breaker), TX1 mid-run cumulative cap
fires via record(), parallel Promise.all scopes do not bleed trackers.

All 255 existing gateway tests and 50 brainstorm tests still pass.

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

* chore(audit): T4 migrate 4 audit writers to shared isoWeekFilename helper

Q1: extract the ISO-week filename math into one canonical helper
(src/core/audit-week-file.ts, landed in T2) and migrate every audit
JSONL writer in the codebase to consume it.

Sites migrated:
  - src/core/minions/handlers/shell-audit.ts  (shell-jobs-YYYY-Www.jsonl)
  - src/core/facts/phantom-audit.ts            (phantoms-YYYY-Www.jsonl)
  - src/core/audit-slug-fallback.ts            (slug-fallback-YYYY-Www.jsonl)
  - src/core/cycle/budget-meter.ts             (dream-budget-YYYY-Www.jsonl)

Each call site had its own copy of the ISO-week-from-Date algorithm.
They mostly agreed but subtle drift was already accumulating (one used
local time, one approximated the Thursday-anchor formula, etc.). One
helper, one set of regression tests, no drift.

Compute helpers (computeAuditFilename, computePhantomAuditFilename,
computeSlugFallbackAuditFilename) are preserved as thin wrappers so
existing import sites and tests don't break.

All audit + slug-fallback + phantom + budget-meter tests still pass.

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

* feat(cycle): T5 BudgetMeter schema_version=1 + golden fixture (A2 amended)

Adapter pass: the existing BudgetMeter keeps its public shape
(`BudgetMeter`, `SubmitEstimate`, `BudgetCheckResult`) verbatim so every
dream-cycle call site keeps working without rewires. The audit JSONL
grew one new field on every line: `schema_version: 1`.

A2 amended: the codex outside-voice review relaxed the byte-stable
contract to schema-stable. Field reorderings are tolerated; the
documented set (schema_version, ts, phase, event, model, label,
plus per-event cost or token fields) is what every consumer can rely
on. Renames or removals are breaking.

test/fixtures/dream-budget-schema-v1.jsonl carries one canonical row
per event variant (submit / submit_denied / submit_unpriced) as
documentation of the schema. The new in-suite case in
test/budget-meter.test.ts walks every emitted line and asserts the
fields are present + the right type.

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

* feat(eval): T6 wrap eval-contradictions runner in withBudgetTracker

The runner now installs a BudgetTracker scope around its body so every
gateway-layer chat / embed / rerank call (the judge model + per-query
embedding) auto-records via the AsyncLocalStorage from T3. Currently
telemetry-only — the existing CostTracker remains the primary soft-
ceiling enforcement, so the public --budget-usd surface and
PreFlightBudgetError shape are byte-identical.

The wiring is the seam: future waves can promote the cap to BudgetTracker
semantics (TX1 + TX2 semantics on cumulative + no_pricing) by passing
maxCostUsd through to BudgetTracker without touching the CLI.

All 79 eval-contradictions tests pass.

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

* feat(doctor): T7 --remediate budget tracker + checkpoint + --resume (A4)

A4 amended: doctor --remediate gains a resumable cost ceiling. The
runRemediate loop now runs inside `withBudgetTracker(tracker, ...)` so
every gateway-routed LLM call inside a Minion handler (synthesize,
patterns, consolidate, embed) honors the cap. When BudgetExhausted
fires mid-run, the onExhausted callback persists a checkpoint of
completed step ids + idempotency_keys to
~/.gbrain/remediation/<plan_hash>.json BEFORE the throw propagates,
and the catch surfaces a paste-ready --resume hint.

Wire-up:
  - New --resume <plan_hash> flag (with implicit "most recent matching"
    when no hash given) loads the checkpoint and skips already-
    completed steps. Mismatched plan_hash refuses with an explicit
    message.
  - --max-cost is now an alias for --max-usd. Both spellings honored
    and threaded through to BudgetTracker.maxCostUsd so the cap is
    a real ceiling, not just pre-flight advice.
  - On BudgetExhausted, exit 1 with the resume hint; on clean
    completion, clear the checkpoint.

New file: src/core/remediation-checkpoint.ts with
computePlanHash / save / load / list / clear helpers. Atomic write
via .tmp + rename. Pinned by 13 unit cases including determinism +
sort-order invariance + schema-mismatch return-null + atomic-rename.

All 48 doctor.test.ts cases still pass.

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

* docs(subagent): T8 A1 ordering ASCII diagram before acquireLease

Documents the load-bearing ordering invariant: the gateway's
BudgetTracker reserve() runs (implicitly, via AsyncLocalStorage)
BEFORE acquireLease() inside the subagent loop. A BudgetExhausted
throw must NOT consume a rate-lease slot, because the lease is the
rate-limit pacer for the entire fleet.

The handler body intentionally does NOT explicitly thread BudgetTracker;
TX5 (gateway-layer composition) handles that. The comment is the
reader's signpost.

No behavioral change. All 58 subagent tests still pass.

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

* feat(diarize): T9 payload-fitter (P6) with batch + summarize + gate

Generic utility for fitting arbitrarily-large item lists into a
downstream caller's per-call token budget. Two strategies:

  - 'batch': deterministic token-budgeted chunking. No LLM calls. The
    fitted list shape matches the input; the caller decides how to
    consume it (e.g. brainstorm judge concatenates per-chunk results).
    Surfaces a `dropped` count for items that exceed the per-call cap.

  - 'summarize': embed-cluster into ceil(items/4) groups via cheap
    deterministic nearest-neighbor on cosine; Haiku-summarize each
    cluster via Promise.allSettled at parallelism=4 (Perf1). Each
    Haiku call composes the active BudgetTracker via the gateway's
    AsyncLocalStorage scope (T3) — no per-call injection.

Quality gate (codex outside-voice finding #4): when summarize's
success_ratio < min_success_ratio (default 0.75), the result is
flagged `degraded: true` so the caller (brainstorm) can decide to
surface a partial result or abort. The fitter itself preserves the
successful subset either way.

Tested via 4 cases across two files (T3 contract):
  - happy path (all clusters succeed → degraded=false)
  - partial failure tolerated (1/5 fails, success_ratio=0.8 > 0.75 → degraded=false)
  - high-failure rate flips the gate (3/5 fails → degraded=true)
  - budget-respecting (BudgetExhausted thrown mid-cluster propagates
    via Promise.allSettled)

11 unit cases across batch + summarize. Brainstorm + cost-guardrails
tests still green; judges.ts internal chunking deferred to a follow-up
wave (TODOS) so the existing chunked-batch contract stays byte-stable
during this drop.

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

* feat(brainstorm): T10 checkpoint + --resume with full idea bodies (P7)

The brainstorm cathedral capstone. Crashed runs can resume cleanly via
`gbrain brainstorm --resume <run_id>` (and `gbrain lsd --resume` etc).

TX3 load-bearing contract: completed_crosses on disk carries FULL idea
bodies (~50KB per run), not just counts. The resumed BrainstormResult
contains the pre-crash ideas (loaded from disk) merged with the post-
resume ideas — codex's outside-voice finding was that a resume that
produces only "what we generated this run" is silent partial output.

TX4 single rule: --resume continues any cross not in completed_crosses.
The proposed --retry-failed was dropped per codex review; failed AND
never-attempted crosses both go through --resume.

A5 amended: run_id = sha256(question + profile + sort(close_slugs) +
sort(far_slugs)).slice(0,16). NO embedding bits — stable across
embedding-model swaps. 7-day mtime-based GC.

Q2 fold: orchestrator.ts drops its inline BudgetExhausted class and
re-exports the canonical one from src/core/budget/budget-tracker.ts
(Phase 2). runBrainstorm now wraps the body in withBudgetTracker so
every gateway-layer chat call auto-records cost. The cap remains
opts.maxCostUsd (default $5).

New CLI flags:
  --resume <run_id>   Continue any cross not in completed_crosses.
                      Refuses to start when run_id doesn't match the
                      active inputs (paste-ready hint).
  --force-resume      Bypass the 7-day staleness gate.
  --list-runs         Print saved run_ids and exit.

Cycle purge phase (the 9th cycle phase) now also GCs stale brainstorm
checkpoints alongside op_checkpoints (~7d window).

Tests:
  - 20 unit cases in test/brainstorm/checkpoint.test.ts:
    computeRunId is deterministic + slug-array-order invariant + stable
    across embedding-model swaps; round-trip preserves ideas verbatim;
    saveCheckpoint atomic via .tmp+rename; loadCheckpoint returns null
    on missing/schema-mismatch/corrupt-JSON; gcStaleCheckpoints unlinks
    >N days; listRuns mtime-ordered.
  - 3 E2E cases in test/e2e/brainstorm-resume.test.ts:
    crash on cross 4 → first run aborts with checkpoint of crosses 1..N
    with full idea bodies; second run with resumeRunId merges pre-crash
    + post-resume ideas (TX3 contract); mismatched run_id refuses with
    paste-ready hint.

The PGLite schema-gap workaround in the E2E (CREATE VIEW page_links AS
SELECT * FROM links) is filed as a follow-up in TODOS T12 — the
real-engine brainstorm path needs that view to materialize as a
canonical schema fix.

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

* docs: T11 + T12 wave release docs + deferred follow-ups

CHANGELOG entry for the brainstorm cost cathedral (Unreleased slot;
/ship will assign the next version):
  - ELI10 lead per CLAUDE.md voice rules
  - "How to turn it on" with paste-ready commands
  - "Things to watch" calls out the A4 semantic shift for
    `doctor --remediate --max-usd` (pre-flight → mid-run abort
    with resumable checkpoint)
  - Itemized changes by file/area
  - "For contributors" section noting the 73 new tests + the PGLite
    schema-gap workaround for the E2E

CLAUDE.md Key Files: 6 new entries for budget-tracker, audit-week-file,
gateway withBudgetTracker, payload-fitter, brainstorm/checkpoint,
remediation-checkpoint. Regenerated llms-full.txt + llms.txt (passes
test/build-llms.test.ts).

docs/incidents/2026-05-20-lsd-cost-explosion.md gains a closing
"Shipped in v0.37.x (the budget cathedral wave)" section listing P1-P7
completion status + the deferred follow-ups so the incident's audit
trail closes the loop.

TODOS.md gets a new top section for the wave's deferred items:
  - PGLite `page_links` schema gap fix
  - Explicit --max-cost on extract / enrich / integrity auto
  - P5 config-schema budgets: block in ~/.gbrain/config.json
  - Multi-day brainstorm resume (>7d)
  - Async-batched audit writes (profiling trigger criterion)
  - BudgetLedger unification with BudgetTracker
  - judges.ts internal chunking → payload-fitter delegation

Also: fixed a payload-fitter typecheck error (ChatFn import). Final
typecheck is clean on every file the wave touched.

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

* fix(schema): F1 page_links view alias for both engines

Brainstorm's domain-bank queries reference `page_links` (pglite-engine.ts:896,
postgres-engine.ts:959) but the canonical table is `links`. Without the alias
view, `gbrain brainstorm` against PGLite fails with `relation "page_links"
does not exist`; the same was a latent bug on Postgres.

This commit lands the fix at three sites:

1. `src/core/pglite-schema.ts` — embedded schema bundle gets the view at
   table-bundle time, so fresh PGLite installs are correct from boot.
2. `src/core/migrate.ts` v81 (`page_links_view_alias`) — existing brains on
   either engine pick up the view via `gbrain apply-migrations`. CREATE OR
   REPLACE VIEW is idempotent; re-running is safe.
3. `test/e2e/brainstorm-resume.test.ts` — removed the ad-hoc workaround view
   from the test setup. The E2E now exercises the same schema path real
   users will see.

`TODOS.md` entry for the gap closed out.

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

* test(brainstorm): F2 pre-flight --max-cost refusal smoke E2E

Pins the user-facing path that closed the original \$50 incident: when
the pre-run estimate exceeds the configured cap, runBrainstorm throws
BudgetExhausted with reason='cost' and a paste-ready hint pointing at
--limit / --max-cost / --max-far-set before any chat call happens.

The four assertions are the four things a real user can verify after
the throw lands:
  1. Typed BudgetExhausted (not a generic Error)
  2. reason === 'cost' (not runtime or no_pricing)
  3. Message names the remediation flags
  4. No provider HTTP would have happened (chat.crossCalls === 0)

Uses the same PGLite engine + tinyProfile + stub chatFn as the existing
--resume tests. Hermetic; ~5s wallclock.

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

* feat(reindex-code): F3 --max-cost flag via withBudgetTracker

Wires gbrain reindex --code into the v0.38 budget cathedral. When the
caller passes --max-cost N (or --max-cost-usd N), runReindexCode wraps
its per-page import loop in withBudgetTracker so every gateway.embed()
call inside importCodeFile auto-composes the cap. On BudgetExhausted,
the partial-progress result reports what got reindexed before the cap
fired plus a synthetic failure row naming the cap throw.

reindex-code is idempotent (content_hash short-circuit in importCodeFile),
so a re-run after a budget abort picks up where the cap fired — no
manual checkpoint state needed.

Both --max-cost and --max-cost-usd are accepted (symmetry with brainstorm
which uses --max-cost, and a precedent for the spelling we want long-term).

When --max-cost is unset, the body runs outside any tracker scope — byte-
stable pre-F3 behavior for legacy callers.

Files:
  src/commands/reindex-code.ts:
    - ReindexCodeOpts.maxCostUsd?: number
    - runReindexCode wraps body in withBudgetTracker when set
    - runReindexCodeCli parses --max-cost / --max-cost-usd
    - BudgetExhausted caught + returned as partial-progress result
  test/reindex-code-max-cost.serial.test.ts (NEW):
    - dry-run + maxCostUsd happy path
    - empty-brain + maxCostUsd hits early-return cleanly
    - no tracker installed when cap is unset (regression guard for
      the conditional wrap)

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

* fix(schema): narrow page_links view projection to bootstrap-safe columns

The v0.38 page_links view alias initially used SELECT * FROM links, which
broke the pre-v0.13 bootstrap test: applyForwardReferenceBootstrap drops
link_source + origin_page_id to simulate the pre-v0.13 schema shape, but
the SELECT * view created a dependency that blocked the column DROP.

Engine queries only reference pl.id (via COUNT(*)) and pl.to_page_id, so
the view's projection is now SELECT id, from_page_id, to_page_id FROM links
— what callers actually use, no more. This unblocks legacy-brain upgrade
paths AND keeps the bootstrap forward-reference probes safe.

Bootstrap suite: 15/15 pass after the change.

Also files a P0 TODO for a pre-existing test failure
(test/doctor-report-remote.test.ts "full report on healthy brain") that
fails on master too — out of scope for this wave but noticed during
/ship triage.

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

* chore: bump version to v0.39.0.0

Brainstorm cost cathedral wave (P1-P7). MINOR bump per user direction:
new architectural seam (gateway-layer BudgetTracker via AsyncLocalStorage),
5 new modules, new CLI flags (--max-cost / --resume / --list-runs /
--force-resume), new migration v81 (page_links view alias).

No breaking changes — BudgetExhausted re-exported from orchestrator for
back-compat; --max-usd preserved as alias for --max-cost; eval-contradictions
--budget-usd surface byte-identical.

CHANGELOG entry renamed from [Unreleased] to [0.39.0.0] and adds the
mandatory "To take advantage of v0.39.0.0" block per CLAUDE.md.

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

* test(isolation): rename 3 env-mutating tests to .serial.test.ts (CI fix)

CI's `check:test-isolation` flagged three tests added in the v0.39.0.0
cathedral that directly mutate `process.env` across test boundaries:

- test/brainstorm/checkpoint.test.ts (mutates GBRAIN_HOME)
- test/core/audit-week-file.test.ts (mutates GBRAIN_AUDIT_DIR)
- test/core/remediation-checkpoint.test.ts (mutates GBRAIN_HOME)

Per CLAUDE.md rule R1: env-mutating tests either use withEnv() OR rename
to *.serial.test.ts (the quarantine escape hatch). The mutation lives in
beforeEach/afterEach which spans the whole describe block, so .serial
rename is the cleaner fix — withEnv() would require restructuring every
test. The serial-test runner gives them their own bun process; no cross-
file env races.

Verified: check:test-isolation passes (527 non-serial unit files clean),
`bun run verify` passes, all 41 tests in the three renamed files 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-22 11:53:11 -07:00
29961811a4 v0.31.12 fix: canonical Anthropic model IDs + tier routing surface + gbrain models CLI (#844)
* fix: canonical Anthropic model IDs + reverse alias + Opus 4.7 pricing

Replace claude-sonnet-4-6-20250929 with claude-sonnet-4-6 everywhere it
appears as a model ID. Starting with Claude 4.6, Anthropic API IDs are
dateless and pinned — the date suffix was carried forward from Sonnet 4.5
by mistake, producing a phantom ID that 404'd on every call.

Production impact in v0.31.6: isAvailable("chat") returned false in every
code path that loaded the recipe's model list, and extractFactsFromTurn
silently returned []. The headline real-time facts extraction feature
was a no-op on the happy path.

- gateway.ts:46 DEFAULT_CHAT_MODEL -> anthropic:claude-sonnet-4-6
- recipes/anthropic.ts: chat + expansion model lists drop date suffix;
  remove wrong-direction alias (claude-sonnet-4-6 -> -20250929);
  add reverse alias (-20250929 -> claude-sonnet-4-6) so stale user
  configs in models.dream.synthesize etc. keep working
- facts/extract.ts: routes through resolveModel; both fallbacks corrected
- anthropic-pricing.ts: Opus 4.7 corrected $15/$75 -> $5/$25 per
  Anthropic docs (the $15/$75 was Opus 4.0 pricing)
- cross-modal-eval/runner.ts: PRICING now reads from ANTHROPIC_PRICING
  for Anthropic models instead of duplicating the map (single source of
  truth — fixes the drift trap that motivated this whole patch)

Tests: cherry-pick PR #830's test/anthropic-model-ids.test.ts verbatim
(6 recipe-shape guardrails). Update gateway-chat tests to assert reverse
alias resolves correctly. Update budget-meter test for new Opus pricing.

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

* feat: model tier system + recipe-models merge + async reconfigure hook

Add 4-tier model routing (utility/reasoning/deep/subagent) so users can
swap defaults with one config key. Each tier maps to a class of work;
override globally via models.default or per-tier via models.tier.<tier>.

Codex flagged three real architecture issues in the v0.31.12 plan review;
this commit addresses each.

F3 — sync/async timing of configureGateway:
  - buildGatewayConfig stays synchronous (pre-engine-connect callers
    keep working)
  - New reconfigureGatewayWithEngine(engine) async function re-resolves
    expansion + chat defaults through resolveModel after engine.connect()
  - cli.ts wires the re-stamp into the post-connect path

F4/F5 — softening assertTouchpoint was too broad:
  - Earlier plan was to flip native-recipe validation from throw to warn,
    affecting gateway.chat AND gateway.expand AND gateway.embed
  - Instead: per-gateway-instance recipe-models merge. assertTouchpoint
    gets an optional extendedModels Set; when the user opted into a model
    via config, it bypasses the throw. Source-code typos still fail fast.
  - Existing contract test (test/ai/gateway-chat.test.ts:106) preserved

Tier defaults are TIER_DEFAULTS in model-config.ts. Resolution chain
inserts at step 5 (between models.default and env var). Each existing
resolveModel call site gains a tier: arg — think (deep), cycle/synthesize
(reasoning + utility for verdict), patterns/drift (reasoning), auto-think
(deep), facts/extract (reasoning).

Plus 10 new tests pinning tier precedence, subagent-tier fallback when
models.default is non-Anthropic, and the F6 alias-chain conflict case.

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

* feat: subagent runtime enforcement for non-Anthropic models (3 layers)

The subagent loop uses Anthropic's Messages API with prompt caching on
system + tools. OpenAI/Google have different shapes. Setting
models.default = openai:gpt-5.5 and routing the subagent there silently
breaks the loop.

Codex F1+F2+F13 in the v0.31.12 plan review pointed out that "warn at
doctor" wasn't enough — handlers/subagent.ts:148 still did
`const model = data.model ?? DEFAULT_MODEL` and called Anthropic directly,
so a job submitted with data.model = openai:gpt-5.5 bypassed any tier
logic and failed at runtime with a confusing provider error.

Three layers of enforcement, defense in depth:

Layer 1 (queue.ts:add) — submit-time guard. When name === 'subagent'
and data.model is set, validate the provider. Non-Anthropic rejects
before the job enters the queue.

Layer 2 (handlers/subagent.ts) — tier-resolution fallback. The handler
routes through resolveModel({ tier: 'subagent' }). If the chain resolves
to a non-Anthropic provider (via models.default or models.tier.subagent),
the resolver warns + falls back to TIER_DEFAULTS.subagent
(claude-sonnet-4-6).

Layer 3 (doctor.ts:checkSubagentProvider) — surfacing layer. Warns when
models.tier.subagent or models.default is explicitly set to a
non-Anthropic provider, with a paste-ready fix command. Lets users see
config drift before submitting a job.

Tests: 3 new cases in test/agent-cli.test.ts asserting the queue-level
guard rejects non-Anthropic data.model. Existing test/subagent-handler
suite still passes.

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

* feat: gbrain models CLI + doctor probe + silent-no-op regression test

New gbrain models CLI gives the agent and user visibility into routing.
Read mode prints the tier table, current overrides, per-task config,
and aliases with source-of-truth attribution per row. Doctor subcommand
fires a 1-token probe to each configured chat/expansion model and
classifies failures (model_not_found / auth / rate_limit / network /
unknown) so config-time invalid IDs surface without waiting for a
production call that silently degrades.

Per Codex F11 — no specific dollar cost claim in either the help text
or the CHANGELOG (providers have minimum-output billing and prompt-cache
rounding that vary). Probe is opt-in (gbrain doctor --probe-models),
never auto-runs. --skip=<provider> narrows the matrix for cost-sensitive
operators.

Per Codex F7+F8+F15 (the structural regression gap): new
test/facts-extract-silent-no-op.test.ts is THE regression test for the
bug class that motivated v0.31.12. Five cases including the smoking-gun:
when chat IS available, extractFactsFromTurn MUST actually call the chat
transport, not silently return []. Uses the gateway's
__setChatTransportForTests seam so it runs in every shard with no API key.

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

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

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

* docs: document v0.31.12 model tier system + gbrain models CLI

Add CLAUDE.md Key Files annotations for the v0.31.12 work:
src/core/model-config.ts (tier system + isAnthropicProvider + TIER_DEFAULTS),
src/core/ai/model-resolver.ts (assertTouchpoint extendedModels arg),
src/core/ai/gateway.ts (reconfigureGatewayWithEngine + extended-models registry),
src/core/minions/queue.ts (subagent submit-time guard, layer 1 of 3),
src/commands/models.ts (new gbrain models CLI + doctor probe),
src/commands/doctor.ts (subagent_provider check, layer 3 of 3),
src/core/ai/recipes/anthropic.ts (canonical model IDs + reverse alias),
src/core/anthropic-pricing.ts (Opus 4.7 corrected to \$5/\$25).

Add CLAUDE.md commands section for gbrain models + gbrain models doctor
+ power-user config recipes. Add README.md command-table rows for the
same. Regenerate llms-full.txt so the bundled docs stay in sync.

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

* docs: scrub --probe-models reference (flag not actually wired)

The v0.31.12 CHANGELOG and skills/conventions/model-routing.md both
referenced `gbrain doctor --probe-models` as an integrated probe entry
point. The flag was never implemented — only `gbrain models doctor`
landed as the probe surface. Caught by /document-release subagent.

Drop the references rather than wire an untested flag at the last minute.
The probe is reachable via `gbrain models doctor`; users who want it
in doctor's output run that command separately.

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: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:06:31 -07:00
b325f28239 v0.28.6 feat: takes + think + unified model config + per-token MCP allow-list (#563)
* v0.28 schema: takes + synthesis_evidence (v31) + access_tokens.permissions (v32)

Migration v31 adds the takes table (typed/weighted/attributed claims) and
synthesis_evidence (provenance for `gbrain think` outputs). Page-scoped via
page_id FK (slug isn't unique alone in v0.18+ multi-source). HNSW partial
index on embedding for active rows. ON DELETE CASCADE on synthesis_evidence
so deleting a source take cascades the provenance row.

Migration v32 adds access_tokens.permissions JSONB with safe-default
backfill (`{"takes_holders":["world"]}`). Default keeps non-world holders
hidden from MCP-bound tokens until the operator explicitly grants access
via the v0.28 auth permissions CLI.

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

* v0.28 engine: addTakesBatch, listTakes, searchTakes/Vector, supersede, resolve, synthesis_evidence

Extends BrainEngine with the takes domain object. Both engines implement the
same surface; PGLite uses manual `$N` placeholders, Postgres uses postgres-js
unnest() — same shape as addLinksBatch and addTimelineEntriesBatch.

Methods:
- addTakesBatch (upsert via ON CONFLICT (page_id, row_num) DO UPDATE)
- listTakes (filter by holder/kind/active/resolved, takesHoldersAllowList
  for MCP-bound calls, sortBy weight/since_date/created_at)
- searchTakes / searchTakesVector (pg_trgm + cosine; honor allow-list)
- countStaleTakes / listStaleTakes (mirror countStaleChunks pattern;
  embedding column intentionally omitted from listStale payload)
- updateTake (mutable fields only; throws TAKE_ROW_NOT_FOUND)
- supersedeTake (transactional: insert new at next row_num, mark old
  active=false, set superseded_by; throws TAKE_RESOLVED_IMMUTABLE on
  resolved bets)
- resolveTake (sets resolved_*; throws TAKE_ALREADY_RESOLVED on re-resolve;
  resolution is immutable per Codex P1 #13 fold)
- addSynthesisEvidence (provenance persist; ON CONFLICT DO NOTHING)
- getTakeEmbeddings (parallel to getEmbeddingsByChunkIds)

Types live in src/core/engine.ts adjacent to LinkBatchInput. Page-scoped
via page_id (slug not unique in v0.18+ multi-source). PageType gains
'synthesis'. takeRowToTake mapper in utils.ts handles Date → ISO string
normalization.

Tests: test/takes-engine.test.ts — 16 cases against PGLite covering
upsert/list/filter/search happy paths, takesHoldersAllowList isolation,
the four invariant errors (TAKE_ROW_NOT_FOUND, TAKES_WEIGHT_CLAMPED,
TAKE_RESOLVED_IMMUTABLE, TAKE_ALREADY_RESOLVED), supersede flow, resolve
metadata round-trip, FK CASCADE on synthesis_evidence when source take
deletes. All pass.

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

* v0.28 model-config: unified resolveModel with 6-tier precedence + alias resolution

Replaces every hardcoded `claude-*-X` and per-phase `dream.<phase>.model`
config key with a single resolver. Hierarchy:

  1. CLI flag (--model)
  2. New-key config (e.g. models.dream.synthesize)
  3. Old-key config (deprecated dream.synthesize.model, dream.patterns.model)
     — read with stderr deprecation warning, one-per-process
  4. Global default (models.default)
  5. Env var (GBRAIN_MODEL or caller-supplied)
  6. Hardcoded fallback

Aliases (`opus`, `sonnet`, `haiku`, `gemini`, `gpt`) resolve at the end so
any tier can use a short name. User-defined `models.aliases.<name>` config
overrides built-ins. Cycle-safe (depth 2 break). Unknown alias passes
through unchanged so users can pass full provider IDs without registering.

When new-key + old-key are BOTH set (Codex P1 #11 fix), new-key wins and
stderr warns "deprecated config X ignored; Y is set and wins". When only
old-key is set, it's honored with a softer "rename to Y before v0.30"
warning. Both warnings emit once per (key, process) — a Set memo prevents
log spam in long-running daemons.

Migrated call sites: synthesize.ts (model + verdictModel), patterns.ts
(model). subagent.ts and search/expansion.ts to be migrated later in v0.28
(staying compatible until then).

Tests: test/model-config.test.ts — 11 cases pinning the 6-tier ordering,
alias resolution + cycle break, deprecated-key warning emit-once, and
unknown-alias pass-through. All pass.

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

* v0.28 takes-fence: parser/renderer/upserter + chunker strip (privacy P0 fix)

src/core/takes-fence.ts — pure functions for the fenced markdown surface:
- parseTakesFence(body) — extracts ParsedTake[] from `<!--- gbrain:takes:begin/end -->`
  blocks. Strict on canonical form, lenient on hand-edits with warnings
  (TAKES_FENCE_UNBALANCED, TAKES_TABLE_MALFORMED, TAKES_ROW_NUM_COLLISION).
  Strikethrough `~~claim~~` → active=false; date ranges `since → until`
  split into sinceDate/untilDate.
- renderTakesFence(takes) — round-trip safe with parseTakesFence.
- upsertTakeRow(body, row) — append-only per CEO-D6 + eng-D9. Creates a
  fresh `## Takes` section if no fence present. row_num is monotonic
  (max + 1, never gap-filled — keeps cross-page refs and synthesis_evidence
  stable forever).
- supersedeRow(body, oldRow, replacement) — strikes through old row's claim
  AND appends the new row at end. Both rows preserved in markdown for
  git-blame archaeology.
- stripTakesFence(body) — removes the fenced block entirely. Used by the
  chunker so takes content lives ONLY in the takes table.

Codex P0 #3 fix: src/core/chunkers/recursive.ts now calls stripTakesFence()
before computing chunk boundaries. Without this, page chunks would contain
the rendered takes table and the per-token MCP allow-list would be
bypassed at the index layer (token bound to takes_holders=['world'] would
see garry's hunches via page hits). Doctor's takes_fence_chunk_leak check
(plan-side) asserts no chunk contains the begin marker.

Tests: 15 cases covering canonical parse, strikethrough, date range, fence
unbalanced detection, malformed-row skip + warning, row_num collision
detection, round-trip render, append-only upsert into existing fence,
fresh-section creation, monotonic row_num under hand-edit gaps, supersede
flow, stripTakesFence verifying takes content removed AND surrounding
prose preserved. Existing chunker tests still pass (15 + 15 = 30).

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

* v0.28 page-lock: PID-liveness file lock for atomic markdown read-modify-write

src/core/page-lock.ts — per-page file lock at
~/.gbrain/page-locks/<sha256-of-slug>.lock so two concurrent `gbrain takes
add` calls or `takes seed --refresh` from autopilot can't race on the
same `<slug>.md` read-modify-write. Eng-review fold: reuses the v0.17
cycle.lock pattern (mtime + PID liveness) but per-slug.

Differences from cycle.ts's lock:
- SHA-256 of slug for safe filenames (slashes, unicode, etc.)
- Same-pid + fresh mtime = LIVE (cycle.ts assumes one lock per process and
  reclaims same-pid; page-lock allows concurrent locks for DIFFERENT slugs
  in one process). mtime expiry still rescues post-crash leftovers.
- 5-min TTL (vs cycle's 30 min — page edits are short)
- `withPageLock(slug, fn)` convenience wrapper with default 30s timeout

API:
- acquirePageLock(slug, opts) → handle | null (poll-with-timeout)
- handle.refresh() / handle.release() (idempotent — only releases if pid matches)
- withPageLock(slug, fn, opts) — acquire + run + release-in-finally

Tests: 10 cases — fresh acquire, live holder returns null, stale-mtime
reclaim, dead-PID reclaim, refresh updates timestamp, foreign-pid release
is no-op, withPageLock callback runs and releases on success/failure,
timeout-throws when held, SHA-256 filename safety for slashes/unicode.
All pass.

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

* v0.28 extract-takes: dual-path phase (fs|db) + since/until_date as TEXT

src/core/cycle/extract-takes.ts — new phase that materializes the takes
table from fenced markdown blocks. Two paths mirror src/commands/extract.ts:

- extractTakesFromFs: walk *.md under repoPath, parse fences, batch upsert
- extractTakesFromDb: iterate engine.getAllSlugs(), parse each page's
  compiled_truth+timeline, batch upsert (mutation-immune snapshot iteration)

Single dispatcher extractTakes(opts) routes by source. Honors:
- slugs filter for incremental re-extract (pipes from sync→extract)
- dryRun: count would-be upserts, write nothing
- rebuild: DELETE FROM takes WHERE page_id = $1 before re-insert (clean
  slate when markdown is canonical and DB has drifted)

Schema fix: since_date/until_date were DATE in the original v31 migration.
Spec uses partial dates ('2017-01', '2026-04-29 → 2026-06') that Postgres
DATE rejects. Changed to TEXT in both the Postgres and PGLite blocks so
parser-rendered ranges round-trip cleanly. Loses the ability to do
date-range arithmetic in SQL, but date math on opinion timelines is
out of scope for v0.28 anyway. utils.ts dateOrNull now annotated as
v0.28 TEXT-aware.

Migration v31 has not been deployed yet (this branch is the v0.28 release
candidate), so the type swap is free. No data migration needed.

Tests: test/extract-takes.test.ts — 5 cases against PGLite covering full
walk + fence-skip on no-fence pages, takes-table populated post-extract,
incremental slugs filter, dry-run no-write, rebuild=true clears + re-inserts
ad-hoc rows. test/takes-engine.test.ts (16), test/takes-fence.test.ts (15)
all still pass — 36/36 takes tests green.

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

* v0.28 takes CLI: list, search, add, update, supersede, resolve

src/commands/takes.ts — surfaces the engine methods + takes-fence library
through a single `gbrain takes <subcommand>` entrypoint:

  takes <slug>                          list with filters + sort
  takes search "<query>"                pg_trgm keyword search across all takes
  takes add <slug> --claim ... ...      append (markdown + DB, atomic via lock)
  takes update <slug> --row N ...       mutable-fields update (markdown + DB)
  takes supersede <slug> --row N ...    strikethrough old + append new
  takes resolve <slug> --row N --outcome  record bet resolution (immutable)

Markdown is canonical. Every mutate command:
  1. acquires the per-page file lock (withPageLock)
  2. re-reads the .md file
  3. applies the edit via takes-fence (upsertTakeRow / supersedeRow)
  4. writes the .md file back
  5. mirrors to the DB via the engine method
  6. releases the lock (auto via finally)

Resolve currently writes only to DB — surfacing resolved_* in the markdown
table is deferred to v0.29 (the takes-fence renderer's column set is
fixed at # | claim | kind | who | weight | since | source per spec).

Wired into src/cli.ts dispatch + CLI_ONLY allowlist. Help text follows the
project convention (orphans/embed/extract pattern). --dir flag overrides
sync.repo_path config when working outside the configured brain.

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

* v0.28 MCP + auth: takes_list / takes_search / think ops + per-token allow-list

OperationContext gains takesHoldersAllowList — server-side filter for
takes.holder field threaded from access_tokens.permissions through dispatch
into the engine SQL. Closes Codex P0 #3 at the dispatch layer (chunker
strip already closed the page-content side in the previous commit).

src/core/operations.ts — three new ops:
- takes_list: lists takes with holder/kind/active/resolved filters; honors
  ctx.takesHoldersAllowList for MCP-bound calls
- takes_search: pg_trgm keyword search; honors allow-list
- think: op surface registered (returns not_implemented envelope until
  Lane D's pipeline lands). Remote callers cannot save/take per Codex P1 #7.

src/mcp/dispatch.ts — DispatchOpts.takesHoldersAllowList threads into
buildOperationContext.

src/mcp/http-transport.ts — validateToken now reads
access_tokens.permissions.takes_holders, defaults to ['world'] when the
column is absent or malformed (default-deny on private hunches).
auth.takesHoldersAllowList passed to dispatchToolCall.

src/mcp/server.ts (stdio) — defaults to takesHoldersAllowList: ['world']
since stdio has no per-token auth. Operators wanting full visibility use
`gbrain call <op>` directly (sets remote=false).

src/commands/auth.ts — `gbrain auth create <name> --takes-holders w,g,b`
flag persists the per-token list; new `auth permissions <name>
set-takes-holders <list>` updates an existing token.

Tests: test/takes-mcp-allowlist.test.ts — 8 cases against PGLite proving
the threading: local-CLI sees all holders, ['world'] returns only public,
['world','garry'] returns 2/3, no-overlap returns empty (no fallback),
search honors allow-list, remote save/take on think rejected with
not_implemented envelope.

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

* v0.28.0: ship-prep — VERSION, CHANGELOG, migration orchestrator, skill

Closes the v0.28 ship-prep cycle. Bumps VERSION + package.json + bun.lock
to 0.28.0. v0_28_0 migration orchestrator runs three idempotent phases on
upgrade:

- Schema verify: asserts schema_version >= 32 (migrations v31 + v32 already
  applied by the schema runner during gbrain upgrade); fails clean if not.
- Backfill takes: inline runs `extractTakes(engine, { source: 'db' })` so
  any pre-existing fenced takes tables in markdown populate the takes
  index. Idempotent; ON CONFLICT DO UPDATE keeps the table in sync.
- Re-chunk TODO: queues a pending-host-work entry asking the host agent
  to re-import pages with takes content so the v0.28 chunker-strip rule
  (Codex P0 #3 fix) applies retroactively. Pages imported under v0.28+
  already have takes content stripped from chunks at index time; this
  TODO catches up legacy pages.

skills/migrations/v0.28.0.md — agent-readable upgrade guide. Walks
through doctor verification, deprecated-key migration, MCP token
visibility configuration, and a "try the takes layer" smoke test.

CHANGELOG.md — v0.28.0 release-summary in the GStack voice (no AI
vocabulary, no em dashes, real numbers from git diff stat) + the
mandatory "To take advantage of v0.28.0" block + itemized changes by
subsystem (schema, engine, markdown surface, model config, MCP+auth,
CLI, tests, accepted risks).

Final test sweep: 65/65 v0.28 tests pass across 6 files. typecheck clean.

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

* v0.28 think pipeline: gather → sanitize → synthesize → cite-render → CLI

src/core/think/sanitize.ts — prompt-injection defense for take claims:
14 jailbreak patterns (ignore-prior, role-jailbreak, close-take tag,
DAN, system-prompt overrides, eval-shell hooks) plus structural framing
(takes wrapped in <take id="..."> tags the model is told to treat as
DATA). Length-cap at 500 chars. Renders evidence blocks for the prompt.

src/core/think/prompt.ts — system prompt + structured-output schema.
Hard rules: cite every claim, mark hunches/low-weight explicitly,
surface conflicts (never silently pick), surface gaps. JSON schema
with answer + citations[] + gaps[]. Prompt adapts to anchor / time
window / save flag.

src/core/think/cite-render.ts — structured citations + regex fallback
(Codex P1 #4 fold). normalizeStructuredCitations validates the model's
structured output; parseInlineCitations is the body-scan fallback when
the model omits the structured field. resolveCitations dispatches and
records CITATIONS_REGEX_FALLBACK warning when used.

src/core/think/gather.ts — 4-stream parallel retrieval:
  1. hybridSearch (pages, existing primitive)
  2. searchTakes (keyword, pg_trgm)
  3. searchTakesVector (vector, when embedQuestion fn supplied)
  4. traversePaths (graph, when --anchor set)
RRF fusion (k=60). Each stream wrapped in try/catch — partial gather
beats no synthesis. Honors takesHoldersAllowList for MCP-bound calls.

src/core/think/index.ts — runThink orchestrator + persistSynthesis:
INTENT (regex classify) → GATHER → render evidence blocks → resolveModel
('models.think' → 'models.default' → GBRAIN_MODEL → opus) → LLM call
(injectable client) → JSON parse with code-fence + fallback strip →
resolveCitations → ThinkResult. persistSynthesis writes a synthesis
page + synthesis_evidence rows (page_id resolved per slug; page-level
citations skip evidence). Degrades gracefully without ANTHROPIC_API_KEY.
Round-loop scaffolding in place (rounds=1 only path exercised in v0.28).

src/commands/think.ts — `gbrain think "<question>"` CLI. Flag parsing
strips --anchor, --rounds, --save, --take, --model, --since, --until,
--json. Local CLI = remote=false, so save/take honored. Human-readable
output by default; --json for agent consumption.

operations.ts — `think` op now calls runThink (was a not_implemented
stub). Remote callers can't save/take per Codex P1 #7. Returns full
ThinkResult plus saved_slug + evidence_inserted.

cli.ts — wired into dispatch + CLI_ONLY allowlist.

Tests: test/think-pipeline.test.ts — 18 cases against PGLite covering
sanitize patterns, structural rendering, citation parsing (structured +
regex fallback + dedup + invalid-slug rejection), gather streams +
allow-list filter, full pipeline with stub client, malformed-LLM
fallback path, no-API-key graceful degradation, persistSynthesis writes
page + evidence rows. All pass.

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

* v0.28 dream phases: auto-think + drift + budget meter (Codex P1 #10 fold)

src/core/anthropic-pricing.ts — USD/1M-tokens map for Claude 4.7 family
plus older aliases. estimateMaxCostUsd returns null on unpriced models so
the meter caller can warn-once and bypass the gate.

src/core/cycle/budget-meter.ts — cumulative cost ledger. Each submit
estimates max-cost from (model + estimatedInputTokens + maxOutputTokens),
accumulates per-cycle, refuses next submit when projected > cap. Codex
P1 #10 fold: non-Anthropic models (gemini, gpt) bypass with one stderr
warn per process and `unpriced=true` on the result. Budget=0 disables
the gate. Audit trail at ~/.gbrain/audit/dream-budget-YYYY-Www.jsonl.

src/core/cycle/auto-think.ts — auto_think dream phase. Reads
dream.auto_think.{enabled,questions,max_per_cycle,budget,cooldown_days,
auto_commit}. Iterates configured questions through runThink with the
BudgetMeter pre-checking each submit. Cooldown timestamp written ONLY on
success (matches v0.23 synthesize pattern — retries after partial
failures pick back up). When auto_commit=true, persists synthesis pages
via persistSynthesis. Default-disabled.

src/core/cycle/drift.ts — drift dream phase scaffold. Reads
dream.drift.{enabled,lookback_days,budget,auto_update}. Surfaces takes
in the soft band (weight 0.3-0.85, unresolved) that have recent timeline
evidence on the same page. v0.28 ships the orchestration; the LLM judge
that proposes weight adjustments lands in v0.29. modelId + meter wired
now so the ledger captures gate state for callers that opt in.

Tests:
- test/budget-meter.test.ts (7 cases) — pricing-map coverage, allow path,
  cumulative-deny, budget=0 disabled, unpriced bypass+warn-once, ledger
  captures all events, ISO-week filename branch.
- test/auto-think-phase.test.ts (9 cases) — auto_think enable/skip,
  questions empty, success → cooldown ts written, cooldown blocks rerun,
  budget exhausted → partial. drift not_enabled, soft-band candidate
  detection, complete + dry-run paths.

All pass. Typecheck clean.

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

* v0.28 e2e Postgres: takes engine + extract + MCP allow-list (12 cases)

test/e2e/takes-postgres.test.ts — full v0.28 takes pipeline against real
Postgres (gated on DATABASE_URL). 12 cases:
- addTakesBatch upsert via unnest() bind path (Postgres-specific)
- listTakes filters: holder, kind, sort=weight, takesHoldersAllowList
- searchTakes pg_trgm + allow-list filter
- supersedeTake transactional path (BEGIN/COMMIT semantics)
- resolveTake immutability — second resolve throws TAKE_ALREADY_RESOLVED
- synthesis_evidence FK CASCADE on take delete
- countStaleTakes + listStaleTakes filter active+null
- extractTakesFromDb populates takes from fenced markdown
- MCP dispatch with takesHoldersAllowList=['world'] returns only world
- MCP dispatch local-CLI path returns all holders
- MCP dispatch takes_search honors allow-list
- think op forces remote_persisted_blocked even for save+take

postgres-engine.ts: addTakesBatch boolean[] serialization fix.
postgres-js auto-detects element type from JS arrays; for booleans it
mis-detects as scalar. Cast through text[] (`'true' | 'false'`) then
SQL-cast to boolean[] — same pattern other batch methods rely on for
type-stable bind shapes.

test/e2e/helpers.ts: setupDB now (a) tolerates non-existent tables in
TRUNCATE (for fresh DBs where v31 hasn't yet created takes/synthesis_evidence)
and (b) calls engine.initSchema() to actually run migrations.

test/takes-mcp-allowlist.test.ts: updated 2 think-op cases to match
Lane D's landed pipeline. They previously asserted not_implemented
envelopes; now they assert remote_persisted_blocked + NO_ANTHROPIC_API_KEY
graceful-degrade behavior.

Run: DATABASE_URL=postgres://localhost:5435/gbrain_test bun test test/e2e/takes-postgres.test.ts
Result: 12/12 pass.

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

* v0.28 dream phases: local DreamPhaseResult type (avoid premature CyclePhase enum extension)

cycle.ts's PhaseResult is shaped {phase, status, summary, details} with a
narrow PhaseStatus enum ('ok'|'warn'|'fail'|'skipped') and CyclePhase enum
that doesn't yet include 'auto_think'/'drift'. The phases ship standalone
in v0.28 (cycle.ts dispatcher integration is v0.28.x); using PhaseResult
forced premature enum extension.

Introduces DreamPhaseResult exported from auto-think.ts:
  { name: 'auto_think'|'drift'; status: 'complete'|'partial'|'failed'|'skipped';
    detail: string; totals?: Record<string,number>; duration_ms: number }

drift.ts re-exports the same type. When v0.28.x wires the dispatcher, the
adapter at the call site can map DreamPhaseResult → PhaseResult cleanly.

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

* v0.28 e2e: access_tokens.permissions JSONB end-to-end (5 cases)

test/e2e/auth-permissions.test.ts — closes the v0.28 token-allow-list
verification loop against real Postgres. Exercises:

- Migration v32 default backfill: new tokens created without a permissions
  column get {takes_holders: ["world"]} via the schema DEFAULT clause.
- Explicit ["world","garry"] → dispatch.takes_list filters to those
  holders only; brain hunches stay hidden from this token.
- ["world"] default-deny token → takes_search hits filtered to public claims.
- {} permissions row (operator tampered) gracefully defaults to ["world"]
  via the HTTP transport's validateToken parsing.
- revoked_at IS NOT NULL → token excluded from active token query.

Avoids the postgres-js JSONB double-encode trap (CLAUDE.md memory): pass
the object directly to executeRaw, no JSON.stringify, no ::jsonb cast.

All 5 pass against pgvector/pgvector:pg16 on port 5435. Combined v0.28
test sweep: 116/116 across 11 files.

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

* v0.28 e2e: chunker takes-strip integration test (Codex P0 #3 verification)

test/e2e/chunker-takes-strip.test.ts — verifies the chunker actually
strips fenced takes content end-to-end through the import pipeline.
This is the Codex P0 #3 fix's verification path: takes content lives
ONLY in the takes table for retrieval, never duplicated in
content_chunks where the per-token MCP allow-list cannot reach.

5 cases:
- chunkText (unit) output never contains TAKES_FENCE_BEGIN/END markers
- chunkText output never contains fenced claim text
- chunkText output retains non-fence prose (no over-stripping)
- importFromContent end-to-end: imported page has chunks but none
  contain fenced content
- takes_fence_chunk_leak doctor invariant: zero rows globally where
  chunk_text matches `<!--- gbrain:takes:%`

Final v0.28 test sweep:
  121 pass, 0 fail, 336 expect() calls, 12 files
  Coverage: schema migrations, engine methods (PGLite + Postgres),
  takes-fence parser, page-lock, extract phase, takes CLI engine
  surface, model config 6-tier resolver, MCP+auth allow-list,
  think pipeline (gather + sanitize + cite-render + synthesize),
  auto-think + drift + budget meter, JSONB end-to-end, chunker
  strip integration. ~95% of v0.28 surface area covered.

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

* fix CI: apply-migrations skippedFuture arrays + http-transport SQL mock

Two CI failures from PR #563:

test/apply-migrations.test.ts (2 fails) — `buildPlan` tests assert exact
skippedFuture arrays at fixed installed-version stamps. Adding v0.28.0 to
the migration registry means it shows up in skippedFuture when the test
runs at installed=0.11.1 / installed=0.12.0. Append '0.28.0' to both
hardcoded arrays.

test/http-transport.test.ts (8 fails) — the FakeEngine mock string-prefix
matches `SELECT id, name FROM access_tokens` to return a row. v0.28's
validateToken now selects `SELECT id, name, permissions FROM access_tokens`
to read the per-token takes_holders allow-list. Mock returned [] on the
new query → validateToken treated every token as invalid → 401.

Fix: mock now matches both query shapes. validTokens row gets a default
`{takes_holders: ['world']}` permission injected when caller didn't
supply one (mirrors the migration v33 column DEFAULT). Updated
FakeEngineConfig type to allow tests to pass explicit permissions.

Verification:
  bun test test/apply-migrations.test.ts → 18/18 pass
  bun test test/http-transport.test.ts   → 24/24 pass
  bun run typecheck                       → clean

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

* fix CI: add scope annotations to v0.28 ops (takes_list/takes_search/think)

test/oauth.test.ts enforces an invariant from master's v0.26 OAuth landing:
every Operation must have `scope: 'read' | 'write' | 'admin'`, and any op
flagged `mutating: true` must be 'write' or 'admin'. My v0.28 ops were added
before master shipped v0.26 + the new invariant; the merge surfaced the gap.

Annotations:
- takes_list   → read
- takes_search → read
- think        → write (mutating: true; --save persists synthesis page)

Verification:
  bun test test/oauth.test.ts → 42/42 pass
  bun run typecheck            → clean

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

* v0.28.2 feat: remote-source MCP + scope hierarchy + whoami (#690)

* refactor(core): extract SSRF helpers from integrations.ts to core/url-safety.ts

src/core/git-remote.ts (next commit) needs isInternalUrl etc. but importing
from src/commands/ would invert the layering boundary (no existing
src/core/ file imports from src/commands/). Extract the SSRF helpers
(parseOctet, hostnameToOctets, isPrivateIpv4, isInternalUrl) into a new
src/core/url-safety.ts and have integrations.ts re-export for backward
compat. test/integrations.test.ts continues to pass without changes (110
existing tests, 214 expects).

Why this matters for v0.28: the upcoming sources --url feature reuses
this SSRF gate for git-clone URL validation. Codex review caught that
re-rolling weaker URL classification would regress on the IPv6/v4-mapped/
metadata/CGNAT bypass forms that integrations.ts already handles.

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

* feat(core): add git-remote module — SSRF-defensive clone/pull + state probe

New src/core/git-remote.ts (~210 lines) for v0.28's remote-source feature:

- GIT_SSRF_FLAGS exported const: -c http.followRedirects=false,
  -c protocol.file.allow=never, -c protocol.ext.allow=never,
  --no-recurse-submodules. Single source of truth shared by cloneRepo
  and pullRepo so a future flag added to one path lands on both.
  Closes the SSRF surfaces codex flagged: DNS rebinding via redirects,
  .gitmodules as a second-fetch surface, file:// scheme in remotes.

- parseRemoteUrl: https-only, rejects embedded credentials and path
  traversal, delegates internal-target classification to isInternalUrl
  from url-safety.ts (covers RFC1918, link-local, loopback, IPv6, CGNAT
  100.64/10, metadata hostnames, hex/octal/single-int bypass forms).
  GBRAIN_ALLOW_PRIVATE_REMOTES=1 escape hatch with stderr warning is
  needed for self-hosted git over Tailscale (CGNAT trips the gate).

- cloneRepo: --depth=1 default (full clone via depth: 0); refuses
  non-empty destDirs; spawns git via execFileSync (no shell injection)
  with GIT_TERMINAL_PROMPT=0 + askpass=/bin/false to prevent credential
  prompts. timeoutMs default 600s.

- pullRepo: -C path + GIT_SSRF_FLAGS + pull --ff-only, same env confine.

- validateRepoState: 6-state decision tree (missing | not-a-dir |
  no-git | corrupted | url-drift | healthy). Used by performSync's
  re-clone branch to recover from rmd clone dirs and refuse syncs on
  url-drift or corruption.

test/git-remote.test.ts (304 lines, 32 tests): GIT_SSRF_FLAGS exact
shape, all parseRemoteUrl rejection cases including dedicated CGNAT
100.64/10 with/without GBRAIN_ALLOW_PRIVATE_REMOTES (codex T3 case),
fake-git harness for argv assertions on cloneRepo/pullRepo, all 6
validateRepoState branches.

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

* feat(core): add scope hierarchy + ALLOWED_SCOPES allowlist

New src/core/scope.ts (~120 lines) for v0.28's scoped MCP feature.

Hierarchy:
  - admin implies all (escape hatch)
  - write implies read
  - sources_admin and users_admin are siblings (different axes —
    sources-mgmt vs user-account-mgmt; neither implies the other)

Exported:
  - hasScope(grantedScopes, requiredScope): the canonical scope check.
    Replaces exact-string-match at three call sites in upcoming commits
    (serve-http.ts:673, oauth-provider.ts:365 F3 refresh, oauth-provider.ts:498
    token issuance). Without this rewrite, an admin-grant token would
    fail to refresh down to sources_admin (codex finding).
  - ALLOWED_SCOPES set + ALLOWED_SCOPES_LIST sorted array (deterministic
    for OAuth metadata wire format and drift-check output).
  - assertAllowedScopes / InvalidScopeError: registration-time gate so
    tokens with bogus scope strings (read flying-unicorn) get rejected
    with RFC 6749 §5.2 invalid_scope at auth.ts:296 + DCR /register +
    registerClientManual. Today's behavior accepts any string silently.
  - parseScopeString: space-separated wire format → array.

Forward-compat: hasScope ignores unknown granted scopes rather than
throwing, so pre-allowlist tokens with weird scope strings continue
working without crashes (registration is the gate, runtime is best-effort).

test/scope.test.ts (178 lines, 35 tests): hierarchy table including
all-implies for admin, sibling non-implication of *_admin scopes,
write→read but not the reverse, F3 refresh-token subset semantics
under hasScope, ALLOWED_SCOPES_LIST sorted-pinning, allowlist
rejection cases, parseScopeString edge cases (undefined/null/empty).

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

* build(admin): scope-constants mirror + drift CI for src/core/scope.ts

The admin React SPA's tsconfig.json scopes include: ['src'] to admin/src/,
so it cannot directly import ../../src/core/scope.ts. The plan considered
widening the include or generating a single source of truth; both options
either couple the SPA to the gbrain monorepo or add a build step. Eng
review picked the boring choice: hand-maintained mirror at
admin/src/lib/scope-constants.ts plus a CI drift check.

Files:
  - admin/src/lib/scope-constants.ts: hand-maintained ALLOWED_SCOPES_LIST
    duplicate, sorted alphabetically to match src/core/scope.ts.
  - scripts/check-admin-scope-drift.sh: extracts the list from each file
    via awk, normalizes via tr/sort, diffs. Exits 0 on match, 1 on drift
    (with full breakdown of which scopes diverged), 2 on internal error.
    Tested both passing and corrupted paths.
  - package.json: wires check:admin-scope-drift into both `verify` and
    `check:all` so any update to src/core/scope.ts that forgets the
    admin-side mirror fails the build.

The Agents.tsx scope-checkbox sites (5 hardcoded locations) get updated
in a later commit to import from this constants file.

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

* feat(oauth): hasScope hierarchy + ALLOWED_SCOPES allowlist at registration

Switch three call sites in oauth-provider.ts from exact-string-match to
hasScope() so the v0.28 sources_admin and users_admin scopes — and the
admin-implies-all + write-implies-read hierarchy in src/core/scope.ts —
work end to end:

- F3 refresh-token subset enforcement at line 365: previously rejected
  admin → sources_admin refresh because exact-match treated them as
  unrelated scopes. gstack /setup-gbrain Path 4 needs admin tokens to
  refresh down to least-privilege sources_admin scope; this fix lands
  that path.

- Token issuance intersection at line 498 (client_credentials grant):
  same hasScope swap so a client whose stored grant is `admin` can mint
  tokens including any implied scope.

- registerClient (DCR /register) and registerClientManual: validate
  every scope string against ALLOWED_SCOPES via assertAllowedScopes.
  Pre-fix the system silently accepted `--scopes "read flying-unicorn"`
  and persisted the bogus string in oauth_clients.scope. Post-fix the
  caller gets RFC 6749 §5.2 invalid_scope. Existing rows with
  pre-allowlist scopes keep working (allowlist gates registration only).

Tests amended in test/oauth.test.ts:
- T1 (eng-review): admin grant CAN refresh down to sources_admin
- T1 sibling: write grant CANNOT refresh up to sources_admin
- ALLOWED_SCOPES allowlist coverage (manual + DCR paths, all 5 valid)
- Scope-annotation contract tests widened to accept the v0.28 union

62 OAuth tests pass.

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

* feat(serve-http): hasScope at /mcp + advertise full ALLOWED_SCOPES

Two changes against src/commands/serve-http.ts:

- Line 195: scopesSupported on the mcpAuthRouter options switches from the
  hardcoded ['read','write','admin'] to Array.from(ALLOWED_SCOPES_LIST).
  Without this, /.well-known/oauth-authorization-server keeps reporting
  the old triple, so MCP clients (Claude Desktop, ChatGPT, Perplexity)
  cannot discover the v0.28 sources_admin and users_admin scopes via
  standard discovery — they would have to be pre-configured out of band.

- Line 673: request-time scope check on /mcp swaps
  authInfo.scopes.includes(requiredScope) for hasScope(...). This was
  the most-cited codex finding: without it, sources_admin tokens could
  not even satisfy a `read`-scoped op (sources_admin doesn't include
  the literal string "read"). hasScope routes through the hierarchy
  table in src/core/scope.ts so admin implies all and write implies
  read at the gate too.

T2 amendment in test/e2e/serve-http-oauth.test.ts: assert
/.well-known/oauth-authorization-server includes all 5 scopes in
scopes_supported. Pre-v0.28 the list was hardcoded to ['read','write',
'admin'] and this assertion would have failed. (The test is
Postgres-gated; runs under bun run test:e2e with DATABASE_URL set.)

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

* feat(core): sources-ops module — atomic clone + symlink-safe cleanup

src/core/sources-ops.ts (~470 lines): pure async functions extracted from
src/commands/sources.ts so the CLI handlers and the new MCP ops share
one implementation.

addSource: D3 atomicity contract from the eng review.
  1. Validate id (matches existing SOURCE_ID_RE).
  2. Q4 pre-flight SELECT — fail loudly with structured `source_id_taken`
     before any clone work. Pre-fix the existing CLI used INSERT…ON
     CONFLICT DO NOTHING which silently no-op'd; with clone-first that
     would orphan the temp dir.
  3. parseRemoteUrl gate (delegates to isInternalUrl from url-safety.ts).
  4. Clone into $GBRAIN_HOME/clones/.tmp/<id>-<rand>/ via the new
     git-remote helpers.
  5. INSERT row with local_path=<final clone dir>, config.remote_url=<url>.
  6. fs.renameSync(tmp/, final/). Rollback on either-side failure unlinks
     the temp dir; rename-failed path also DELETEs the just-INSERTed row
     best-effort.

removeSource: clone-cleanup with realpath+lstat confinement matching
validateUploadPath() shape at src/core/operations.ts:61. String startsWith
is symlink-unsafe and would let $GBRAIN_HOME/clones/<id> → /etc resolve
out of the confine. Two defenses layered:
  - isPathContained (realpath-resolves both sides + parent-with-sep
    string check) rejects symlinks whose target falls outside the
    confine.
  - lstat-then-isSymbolicLink check refuses symlinks whose realpath
    happens to land back inside the confine (defense in depth).

getSourceStatus: returns clone_state via validateRepoState (the 6-state
decision tree from git-remote.ts). Lets a remote MCP caller diagnose
"healthy | missing | not-a-dir | no-git | url-drift | corrupted" without
SSH access to the brain host. listSources additionally exposes
remote_url so callers can see which sources are auto-managed.

recloneIfMissing: T4 follow-up for `gbrain sources restore` after the
clone dir was autopurged — re-clones via the same temp + rename
atomicity contract. Idempotent (returns false when clone is already
healthy).

test/sources-ops.test.ts (~470 lines, 24 tests): pre-flight collision
(Q4), happy paths for both --path and --url, all four D3 rollback paths
(clone-fail before INSERT, INSERT-fail after clone, rename-fail
post-INSERT, atomic temp-dir cleanup), symlink-target-OUTSIDE-clones
(realpath confinement), symlink-target-INSIDE-clones (lstat-check),
removeSource refuses to delete user-supplied paths, refuses "default"
source, getSourceStatus clone_state branches, T4 recloneIfMissing
recovery + idempotent + no-op for path-only sources, isPathContained
unit tests covering subtree / outside / symlink-escape / fail-closed.

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

* feat(operations): whoami + sources_{add,list,remove,status} MCP ops

Five new ops in src/core/operations.ts auto-flow through src/mcp/tool-defs.ts
so MCP clients (Claude Desktop, ChatGPT, Perplexity, OpenClaw) get them via
standard tools/list discovery — no SDK or transport code changes needed.

Operation.scope union widened to add 'sources_admin' and 'users_admin' (the
v0.28 hierarchy from src/core/scope.ts).

whoami (scope: read): introspect calling identity over MCP.
  - Returns `{transport: 'oauth', client_id, client_name, scopes, expires_at}`
    for OAuth clients (clientId starts with gbrain_cl_).
  - Returns `{transport: 'legacy', token_name, scopes, expires_at: null}`
    for grandfathered access_tokens.
  - Returns `{transport: 'local', scopes: []}` when ctx.remote === false.
    Empty scopes (NOT ['read','write','admin']) is the D2 decision —
    returning OAuth-shaped scopes for local callers would resurrect the
    v0.26.9 footgun where code conditionally trusted on
    `auth.scopes.includes('admin')` instead of `ctx.remote === false`.
  - Q3 fail-closed: throws unknown_transport when remote=true AND auth is
    missing OR ctx.remote is the literal `undefined` (cast bypass guard).
    A future transport that forgets to thread auth doesn't get a free
    pass.

sources_add (sources_admin, mutating): register a source by --path
  (existing v0.17 behavior) or --url (v0.28 federated remote-clone path).
  Calls into addSource from sources-ops.ts which owns the temp-dir +
  rename atomicity.

sources_list (read): list registered sources with page counts, federated
  flag, and remote_url. The remote_url field is new — lets a remote MCP
  caller see which sources are auto-managed.

sources_remove (sources_admin, mutating): cascade-delete a source +
  symlink-safe clone cleanup. Requires confirm_destructive: true when the
  source has data.

sources_status (read): per-source diagnostic returning clone_state
  ('healthy' | 'missing' | 'not-a-dir' | 'no-git' | 'url-drift' |
  'corrupted' | 'not-applicable') — lets a remote MCP caller diagnose a
  busted clone without SSH access to the brain host.

test/whoami.test.ts (9 tests): pinned transport-detection for all four
return shapes including Q3 fail-closed throw under both auth=undefined
and remote=undefined cast-bypass paths.

test/sources-mcp.test.ts (16 tests): op-metadata pins (scope, mutating,
localOnly), functional handler shape against PGLite, hasScope-driven
scope-enforcement smoke test simulating the serve-http.ts:673 gate
(read-only token rejected for sources_add; sources_admin token allowed;
admin token allowed for everything; gstack /setup-gbrain Path 4 token
covers all 4 ops), SSRF gate at the op layer.

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

* feat(sync): re-clone fallback when clone is missing/no-git/corrupted

src/commands/sync.ts gets a v0.28-aware front-half. When the source has
config.remote_url, performSync calls validateRepoState before the existing
fast-forward pull path:

  - 'healthy'    → fall through to existing pull (unchanged)
  - 'missing'    → loud stderr "auto-recovery: re-cloning <id>", then
  'no-git'         recloneIfMissing handles the temp-dir + rename. Sync
  'not-a-dir'      continues from the freshly-cloned head.
  - 'corrupted'  → throw with structured hint pointing at sources remove
                   + add (no syncing wrong state).
  - 'url-drift'  → throw with hint pointing at the (deferred) sources
                   rebase-clone command.

Closes the operator-confidence gap: rm -rf $GBRAIN_HOME/clones/<id>/ no
longer breaks future syncs. The next sync sees the missing dir and
recovers via the recorded URL.

src/core/operations.ts: extend ErrorCode with 'unknown_transport' so
whoami's Q3 fail-closed path types check.

test/sources-resync-recovery.test.ts (12 tests): full validateRepoState
state matrix exercised under fake-git, recloneIfMissing recovery from
each degraded state, idempotent on healthy clones, the sync.ts:320
integration path that drives the recovery.

test/sources-ops.test.ts + test/sources-mcp.test.ts: drop the
GBRAIN_PGLITE_SNAPSHOT-disable line so these tests stop forcing cold
init across the parallel-shard runner. With snapshot allowed, init time
drops from 6+s to ~50ms and parallel runs stay under the 5s hook
timeout.

test/sources-mcp.test.ts: tighten scope literal-type so tsc keeps the
union narrow.

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

* feat(cli): sources add --url + restore re-clone, thin-wrapper refactor

src/commands/sources.ts now delegates the data-mutation work to
src/core/sources-ops.ts (added in the previous commit). The CLI handler
parses argv, calls into addSource, and formats output.

Two new flags on `gbrain sources add`:
  - `--url <https-url>` : federated remote-clone path (clone + INSERT +
    rename, atomic rollback on failure).
  - `--clone-dir <path>` : override the default
    $GBRAIN_HOME/clones/<id>/ destination.

Validation rejects mutually-exclusive `--url` + `--path`. Errors from
the ops layer (SourceOpError) propagate through the CLI's standard
error wrapper in src/cli.ts so existing tests that assert throw shape
keep passing.

`gbrain sources restore <id>` (T4 from eng review): if the source has a
remote_url AND the on-disk clone was autopurged, call recloneIfMissing
before declaring success. Clone errors print a WARN with recovery
hints rather than failing the restore — the DB row is what restore
guarantees; the clone is best-effort.

54 sources-related tests pass (existing test/sources.test.ts +
sources-ops + sources-mcp).

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

* feat(doctor,cycle): orphan-clones surface + autopilot purge phase (P1)

addSource's atomicity contract uses a temp dir that gets renamed to the
final clone path. If the process is SIGKILL'd between clone-finish and
rename, the temp dir orphans on disk. Without sweeping these, a brain
server accumulates gigabytes over months of failed `sources add --url`
attempts.

Two layers:

1. `gbrain doctor` now surfaces stale entries. A new orphan_clones check
   walks $GBRAIN_HOME/clones/.tmp/, names anything older than 24h, and
   prints a warn with disk-byte estimate. Operators see the leak before
   `df` complains.

2. The autopilot cycle's existing `purge` phase grows a substep that
   nukes .tmp/ entries past the same 72h TTL the page-soft-delete purge
   uses. Operator behavior stays uniform across all soft-delete-style
   surfaces.

Both layers are filesystem-only (no DB). On a brain that never used
--url cloning, both are no-ops.

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

* build(admin): scope checkboxes source from scope-constants mirror + dist

admin/src/pages/Agents.tsx Register Client modal:
  - useState default sources from ALLOWED_SCOPES_LIST (defaulting `read`
    to true, others false; unchanged UX for the common case).
  - Scope checkbox map iterates ALLOWED_SCOPES_LIST instead of the old
    hardcoded ['read','write','admin'].

Without this commit, even with the v0.28.1 server-side scope hierarchy,
operators registering an OAuth client from the admin UI cannot tick the
new sources_admin / users_admin scopes — defeats the whole gstack
/setup-gbrain Path 4 unblock.

The drift-check CI gate (scripts/check-admin-scope-drift.sh) ensures
this list stays in sync with src/core/scope.ts going forward.

admin/dist/* rebuilt via `cd admin && bun run build`. Old hash bundle
removed; new bundle (224.96 kB / 68.70 kB gzip).

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

* docs: v0.28.1 — remote-source MCP + scope hierarchy + whoami

VERSION + package.json: bump to 0.28.1 (per CLAUDE.md branch-scoped
versioning rule — this branch adds substantial new features on top of
v0.28.0).

CHANGELOG.md: new top-level entry for v0.28.1 in the gstack/Garry voice
(no AI vocabulary, no em dashes, real numbers + commands). Lead
paragraph names what the user can now do that they couldn't before.
"Numbers that matter" table calls out the +5 MCP ops, +2 OAuth scopes,
and the 4-to-0 SSH-step number for gstack /setup-gbrain Path 4. "What
this means for you" closer ties the work to the operator workflow shift.
"To take advantage of v0.28.1" block has paste-ready upgrade commands
including the admin SPA rebuild step. Itemized changes section
describes the architecture cleanly without exposing scope-string
internals to public attack-surface enumeration (per CLAUDE.md
responsible-disclosure rule).

TODOS.md: file 6 follow-ups under a new "Remote-source MCP follow-ups
(v0.28.1)" section: token rotation, migration introspection in
get_health, Accept-header friendliness, sources rebase-clone for
URL-drift recovery, --filter=blob:none partial-clone option, and the
chunker_version PGLite-schema parity codex caught.

README.md: short subsection under the existing sources CLI listing
that names the new --url flag and what auto-recovery does. Capability
framing (no scope-string enumeration).

llms.txt + llms-full.txt: regenerated via `bun run build:llms` so the
documentation bundle reflects the v0.28.1 entry. The build-llms
generator's drift check passes.

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

* test(e2e): sources-remote-mcp — full gstack /setup-gbrain Path 4 round-trip

Spins up `gbrain serve --http` against real Postgres with a fake-git binary
in PATH (so `git clone` is exercised end-to-end without network), registers
two OAuth clients (sources_admin + read-only), mints tokens, calls the new
v0.28.1 MCP ops via /mcp, and asserts the gstack /setup-gbrain Path 4 flow
works end to end.

12 tests cover the full lifecycle:
- whoami over HTTP MCP returns transport=oauth + the right scopes
- /.well-known/oauth-authorization-server advertises all 5 scopes
- sources_add: clone fires, INSERT lands, row carries config.remote_url
- sources_status: clone_state=healthy after add
- sources_list: surfaces remote_url for the new source
- SSRF rejection: sources_add with RFC1918 URL fails at parseRemoteUrl gate
- Scope enforcement: read-only token gets insufficient_scope on sources_add
- Read-only token CAN call sources_list (read-scoped op)
- ALLOWED_SCOPES allowlist: CLI register-client rejects bogus scope
- Recovery: rm clone dir + sources_status reports clone_state=missing
- sources_remove: cascades + cleans up the auto-managed clone dir

Subprocess env threading replicates the v0.26.2 bun execSync inheritance
pattern — bun does NOT inherit process.env mutations, so every CLI
subprocess call passes env: { ...process.env } explicitly.

Cleanup contract mirrors test/e2e/serve-http-oauth.test.ts: revoke any
clients we registered, force-kill the server subprocess on SIGTERM
timeout, surface cleanup failures to stderr without throwing so real
test failures aren't masked.

The base table list in helpers.ts (ALL_TABLES) doesn't include sources
or oauth_clients, so this test explicitly truncates them in beforeAll
to avoid Q4 pre-flight collisions on re-run.

Skipped gracefully when DATABASE_URL is unset.

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

* fix: codex adversarial review — confine remote sources_admin + close SSRF gaps

Pre-ship adversarial review (codex exec) caught five issues. Four ship in
this commit; the fifth (DNS rebinding) is filed as v0.28.x follow-up.

CRITICAL — `sources_admin` tokens over HTTP MCP could plant content at any
host path. The MCP op exposed `path` and `clone_dir` to remote callers; the
op layer trusted them verbatim, then auto-recovery's rm -rf on degraded
state turned that into arbitrary delete primitives. src/core/operations.ts
sources_add handler now drops both fields when ctx.remote !== false. Local
CLI keeps the override (operator trust). Loud logger.warn when a remote
caller tries — visible in the SSE feed without leaking values.

HIGH — Steady-state `git pull --ff-only` bypassed GIT_SSRF_FLAGS entirely.
The legacy helper at src/commands/sync.ts:192 spawned git without the
-c http.followRedirects=false -c protocol.{file,ext}.allow=never
--no-recurse-submodules set that cloneRepo applies. Every recurring sync
was reopening the redirect/submodule/protocol bypass. Routed the call site
at sync.ts:381 through pullRepo from git-remote.ts so initial clone and
ongoing pull share one defensive flag set.

MEDIUM — listSources ignored its `include_archived` flag. The op
advertised the param but the function destructured it as `_opts` and
queried every row. Archived sources' ids, local_paths, and remote_urls
were leaking to read-scoped MCP callers by default. Filter in SQL
(`WHERE archived IS NOT TRUE` unless the flag is set) so archived rows
never reach the wire.

PARTIAL HIGH — IPv6 ULA fc00::/7 and link-local fe80::/10 were not in
the isInternalUrl bypass list. Only ::1/:: and IPv4-mapped IPv6 were
blocked. Added regex-based ULA + link-local rejection to url-safety.ts.

Test coverage:
- test/git-remote.test.ts: 4 new IPv6 cases (ULA fc-prefix + fd-prefix,
  link-local fe80::, public IPv6 still allowed).
- test/sources-mcp.test.ts: 3 new cases pinning the remote/local
  asymmetry (clone_dir override silently ignored over MCP, path nulled,
  local CLI keeps the override).
- test/sources-mcp.test.ts: 2 new cases for include_archived honored.

DNS rebinding (codex finding #3): the current gate is lexical only.
A deliberate attacker who controls a hostname's A/AAAA records can still
resolve to an internal IP. Closing this requires async DNS resolution +
revalidation; filed as v0.28.x follow-up in TODOS.md so the API change
surface (parseRemoteUrl becomes async, every caller updates) lands in
its own PR.

323 tests pass (9 files); 4071 unit tests pass (full suite).

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

* chore: rebump v0.28.1 → v0.28.2 (master collision)

Caught after PR creation. master is at v0.28.1 already; this branch
forked from garrytan/v0.28-release at v0.28.0 and naively bumped to
v0.28.1 without checking the master queue. CI version-gate would have
rejected at merge time (requires VERSION strictly greater than
master's).

Root cause: I bumped VERSION mechanically during plan implementation
(echo "0.28.1" > VERSION) without consulting the queue-aware allocator
at bin/gstack-next-version. /ship Step 12's idempotency check then
classified state as ALREADY_BUMPED and the workflow's "queue drift"
comparison was the safety net I should have hit — but I skipped it.

Files updated:
- VERSION + package.json: 0.28.1 → 0.28.2
- CHANGELOG.md: header + "To take advantage of v0.28.2" subsection
- README.md: sources --url note version reference
- TODOS.md: 7 follow-up entries' version references
- llms.txt + llms-full.txt: regenerated

PR title rewrite via gstack-pr-title-rewrite.sh handled in a separate
gh pr edit call; CI version-gate now passes.

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

---------

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-06 21:14:34 -07:00