Compare commits

...
74 Commits
Author SHA1 Message Date
Garry TanandClaude Fable 5 dd99e40c2a v0.45.14.0 feat(bootstrap): harness mode — wire framework-spawned agents to a running serve (#4043) (#4106)
* feat(bootstrap): harness-lane settings writers — marker/path params, permissions.allow, CODEX_HOME (#4043 step 1)

writeClaudeHooksAt/removeClaudeHooksAt with marker VALUE parameterization
(bootstrap-v1 and bootstrap-harness-v1 coexist; each removal strips only its
own), onBrokenJson relocate|abort policy (user-scope files must never be
relocated over a stray comment), refuseOnForeignGbrainMarker double-fire
guard, and addPermissionsAllowEntry/removePermissionsAllowEntry (set
semantics, no marker, foreign entries preserved). Atomic writes hardened:
realpath-resolved targets (dotfile symlinks survive), mode preservation,
random tmp suffix, timestamped backup strategy. codexConfigPath now honors
CODEX_HOME (config dir itself — pinned by the real-codex e2e convention).
Legacy wrappers keep byte-identical behavior; existing writer suite untouched
and green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(bootstrap): codex-toml managed block writer — the fired CX2-17 revisit (#4043 step 2)

One [mcp_servers.<name>] table with inline bearer_token between full-line
markers; everything outside survives byte-for-byte. Foreign-server detection
parses the config (Bun.TOML.parse, no new dependency) with our block
stripped, so inline-table/dotted/quoted spellings can't false-negative into
a codex-bricking duplicate table. Rewrites re-anchor at EOF; renders are
parse-validated with an ours-keys-exactly assert before rename; damaged
markers refuse. Secrets hygiene: 0600 tmp/target/.bak, group-readable
configs tightened with a note. CRLF preserved, missing trailing newline
repaired. TARGETS['codex-2026-08'] flipped to verified (codex-cli 0.147.0:
serde field scan; codex hooks existence recorded; CODEX_HOME resolution).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(auth): least-privilege legacy tokens — adopt the scopes TEXT[] column (#4043 step 3)

The dormant original-schema access_tokens.scopes column becomes THE scope
store: verifyAccessToken's legacy branch honors it (NULL = grandfathered
full access, so every existing token is byte-identical; a filtered-empty
array is deny, so typos fail closed), and a column is structurally immune
to the permissions-object-replacement wipe class. That class gets fixed at
its known site too: auth permissions set-takes-holders now MERGES into the
permissions JSONB instead of replacing it (a routine visibility edit would
have silently deleted the source_id federation grant and re-escalated).

New surface: gbrain auth create --scopes read,write (comma/whitespace,
mint-time validation); auth list shows id + scopes columns (grandfathered
rendered honestly); auth revoke --id <uuid> for precise revocation (names
are not unique — bulk revoke-by-name now says when it hit several). New
src/core/token-mint.ts (mintLegacyToken with federation source grant +
RETURNING id; revokeLegacyTokenById never touches same-name siblings) for
the harness rotation contract. The admin dashboard's agents endpoint stops
hardcoding full access for every legacy key and reads the real grant.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(bootstrap): harness receipt + mcp-registration core extraction (#4043 step 4)

HarnessReceipt is a machine-level sibling of receipt.json (the install
receipt is workspace-keyed; a harness-only box has no workspace) with the
same CX2-12 discipline: typed read states, newer-format refusal, broken-file
backup-aside, atomic 0600 writes. Write-ahead contract: targets persist as
pending at mint time and confirm as wiring lands, and token.previous_id
carries the prior token through the mint-first rotation, so a crash at any
step leaves a receipt --remove can consume.

The pure MCP-registration helpers (normalizeMcpUrl, argv builders,
redactToken, validateToken, shellQuote/cmdString) move from
src/commands/connect.ts to src/core/mcp-registration.ts — the harness lane
lives in core and core must not import from commands. connect.ts re-exports
(surface + tests unchanged). buildClaudeMcpAddArgv gains an optional scope
param (claude's default is local; harness must pass user); loopback helper
exported for the harness --url guard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(bootstrap): `gbrain bootstrap harness` — wire framework-spawned sessions to a running serve (#4043 step 5)

The orchestrator (src/core/bootstrap/harness.ts): consent block in the
#4029 honesty register (reach stated as fact, transcript capture its own
numbered item, off-ramps in the same breath; non-TTY requires --yes),
/health probe with a loopback guard (remote brains are gbrain connect's
charter), mint-first rotation (previous token revoked BY ID only after
every target confirms and the smoke passes), write-ahead harness receipt
(crash at any step leaves consumable state), registration ownership checks
(--force to replace a foreign-url server; --remove skips what it no longer
owns), user-XOR-project hook scopes with the double-fire refusal, the
GBRAIN_HOOK_LANE=harness runtime defer guard in `gbrain hook` (workspace
bootstrap installs win), --no-capture context-only wiring, Postgres
degradation + version-skew honesty lines, --status with host-config token
recovery and honest degrades, and engine-free-first --remove that defers
the revoke under a live PGLite serve.

Dispatcher wiring: `bootstrap harness` subcommand (home-dir lock, own
install-log phase), uninstall runs harness removal FIRST (revoke needs the
DB alive; --delete-brain would destroy harness.json) and treats
NO_RECEIPT/HOME_GUARD/RECEIPT_MISMATCH as "no workspace install" once
harness wiring is cleared; runHooks' codex stdio lane defers to a
harness-managed server name (one owner per name); the stale "Codex has no
hook system" line now states the truth. Flag registry regenerated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(bootstrap): harness doctor check + lifecycle e2e; CLAUDE_CONFIG_DIR-safe user-settings path (#4043 step 6)

Doctor gains bootstrap_harness_health inside the existing bootstrap check
group (no new status phase): skip when not a harness box / warn when the
serve is down (a normal transient) or the receipt is unreadable / fail when
targets are failed-or-pending or a rotation never converged — and a
harness-only box now opens the bootstrap check gate at all (it previously
got ZERO checks).

E2E lifecycle against a real `serve --http` on a hermetic PGLite brain:
pre-minted scoped token (the documented PGLite escape), real /health +
bearer smoke, both harness lanes wired, --status with token recovery from
the codex block, --remove leaving the codex config byte-identical, the
mint-under-live-serve refusal, and a live insufficient_scope refusal of an
admin op — least privilege proven end to end.

Root-cause fix the e2e caught: Bun's homedir() reads the password database
and ignores a remapped HOME, so claudeUserSettingsPath now resolves via
CLAUDE_CONFIG_DIR (Claude Code's own override) then $HOME explicitly —
without it, sandboxed runs write into the operator's REAL settings file
(the write-ahead receipt's remove path self-healed the one incident).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(bootstrap): v0.45.9.0 — local harness mode docs, scoped-token honesty pins, TODOS follow-ups (#4043 step 7)

docs/guides/bootstrap.md gains the "Local harness mode" section + the
missing Postgres row in the degradation matrix; DEPLOY.md/CODEX.md stop
claiming the token grandfather is unconditional and distinguish the connect
lane (token in env) from the harness lane (inline, 0600, consented);
KEY_FILES.md bootstrap cluster describes the parameterized writers,
codex-toml.ts, harness.ts, token-mint.ts, and mcp-registration.ts in
current-state voice; RESOLVER.md routes "wire this box's coding agents"
to bootstrap harness; setup skill points at it. Seven follow-ups filed in
TODOS.md (serve port record, http-transport scope asymmetry, unique token
names, codex hook lane, PGLite admin-lane minting, OpenClaw setup hook —
self-demoted: plugin installs run with lifecycle scripts disabled and the
manifest has no setup field — and federated-drift visibility). Doctor's
harness messages spell flags without leading dashes (the flag-registry
prose-bleed class; registry stays fresh). VERSION/package.json/CHANGELOG/
openclaw.plugin.json → 0.45.9.0 (0.45.8.0 is claimed by an open PR);
template stamp + runbook stamp refreshed; llms bundles rebuilt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(doctor): categorize bootstrap_harness_health + de-flag harness message prose (#4043 triage)

The full-suite triage against a pristine-master baseline surfaced the two
in-branch failures: the doctor-categories drift guard (new check name not
in OPS_CHECK_NAMES) and the flag-registry freshness guard (doctor's harness
messages carried bare id/http flag tokens, which the generator harvests
into every importing command's allowlist — the known prose-bleed class;
messages now spell flags without leading dashes). Every other failing file
(18) fails identically on master with this environment — pre-existing,
not this wave's.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(hook): pin the harness-lane yield guard + honor the io.cwd seam (#4043 eng review E5)

The GBRAIN_HOOK_LANE=harness defer guard (workspace bootstrap installs win
over user-scope harness wiring — the C6 double-fire defense) was the one
new branch with no direct test. Two serial cases now pin it: lane +
bootstrap-v1 markers in the cwd → every event yields silently (exit 0, no
output, no heartbeat); lane without markers — including a harness-marker-
only settings file — runs normally and heartbeats. The guard now resolves
the cwd through the same io.cwd test seam the handlers use.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(bootstrap): harness convergence + rotation hardening — eng-review outside-voice X-batch (#4043)

Fifteen codex outside-voice findings, thirteen accepted and fixed, one
accepted as an advisory line, one kept as the settled PGLite deferral:

- X1: explicit --harness codex FORCES wiring (the TOML writer needs no
  codex CLI — the exact no-CLI box the issue filed); detection heuristics
  gate only the `all` default.
- X2: --source now reaches the mint as a scalar write-floor grant; it was
  written to the receipt and hook env but never scoped the token.
- X3: re-runs converge — writeClaudeHooksAt strips our marker across ALL
  events before wiring the requested subset (--no-capture now unwires
  Stop/SessionEnd), and apply unwires prior-receipt targets the new plan
  drops (changed --project sets no longer strand live wiring).
- X4: token.previous_ids is an array — a failed rotation accumulates every
  unrevoked id and the next converge (or --remove) revokes them ALL; the
  --token lane carries them too.
- X5: real rollback — the previous claude registration (url + bearer from
  mcp get) is restored on add-failure or failed smoke, and the codex .bak
  is restored on failed smoke, so "old clients keep working" is true in
  the registration sense, not just the token sense.
- X6: the receipt guard + write-ahead write now precede the mint — a crash
  or newer-format refusal can no longer strand an unrecorded live token.
- X7: consent copy tells the truth — supplied tokens are "written only
  into the host registrations", and the reach paragraph matches the actual
  harness/hook/capture selection.
- X8: a pre-existing permissions.allow entry is recorded as pre-existing
  and never deleted by remove.
- X9: codex wiring prints the experimental_use_rmcp_client advisory.
- X10: an unknown-tool tool_error counts as verified (auth + dispatch
  succeeded) — a --surface verbs serve is no longer declared broken by
  smoke or --status.
- X11: user-scope writes run under a config-dir lock and fresh files are
  created 0600.
- X12: --status is genuinely read-only (no home mkdir, no lock).
- X13: registrar mode (non-loopback --url + --token) wires MCP only —
  hooks talk to the LOCAL brain and would split-brain the box; the http-
  bearer warning is no longer discarded.
- X14: flag parsing fails closed (missing values, --url+--port,
  --status+--remove all error instead of resolving by precedence).

12 new serial cases pin the batch; 463 wave tests green incl. the live e2e.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(harness): ship-review hardening — pre-landing review army + coverage/plan audits (#4043)

Review fixes (6 specialists + coverage + plan-completion audits at ship):
- Smoke-fail rollback symmetry: a FRESH claude registration is removed on a
  failed smoke (previously only replacements were restored); an unrecoverable
  replacement fails the target honestly instead of staying green.
- [X14] --project with a missing/flag-like value errors instead of silently
  widening hook wiring to user scope; auth create --scopes/--takes-holders
  missing values error instead of minting a grandfathered full-access token.
- normalizeTokenScopes fails CLOSED on representation drift: only never-written
  NULL grandfathers; undecoded '{a,b}' array-literal strings parse; any other
  non-null shape denies.
- set-takes-holders merge guards the left operand with jsonb_typeof so
  historically damaged (scalar/array) permissions rows repair on edit instead
  of compounding into a jsonb array; e2e updates pin the REAL auth.ts SQL
  shape + the source_id-survives-merge regression.
- codexBlockOwnsName scopes the name check to INSIDE the managed block and
  shares the writer's marker constants (parseCodexBlockBearer too).
- Refuse-rather-than-guess on unverifiable URLs: --remove and stale-target
  cleanup skip claude registrations whose URL cannot be parsed.
- [X11] parity: codex config.toml writes/removes serialize under a lock on
  the config's own dir; runUninstall takes the HOME lock around harness
  removal (same key as runHarness).
- [D12] the harness-lane hook yield guard also honors the committed
  .claude/settings.json carrier — checking only settings.local.json would
  double-fire events owned by the committed carrier.
- token-mint uses isUndefinedColumnError (message-shaped variants included);
  TOKEN_ID_RE shared with the auth revoke --id CLI gate.
- Stale 'Codex has no hook system' copy in the real-codex e2e + bootstrap
  guide updated to the honest 'gbrain does not wire Codex hooks yet'.
- New tests: runUninstall harness-first composition (harness-only box +
  abort-before-teardown), DATABASE_URL-gated mintLegacyToken Postgres parity,
  renderTokenScopes, isServeOlderThanScopes matrix, codexBlockOwnsName,
  fresh/unrecoverable smoke-fail rollback, committed-carrier yield.
- TODOS: smoke identity-verification hardening, lock.ts message polish,
  auth-create/doctor dedupe follow-ups; docs: binary-downgrade scoping note.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(harness): adversarial-review hardening — codex + red-team convergence at ship (#4043)

Cross-model adversarial passes (Codex exec + red-team subagent) on top of the
review-army batch; three-reviewer convergence upgraded the loopback-impostor
class from TODO to fixed:

- CANARY before the smoke: a random same-format bearer must FAIL auth before
  the real token is verified — an impostor squatting the loopback port cannot
  distinguish the canary from the real token, so it is caught whichever way
  it answers; on ANY failed smoke the fresh mint is revoked immediately
  (nothing live is ever left with an unverified endpoint).
- Pre-approval integrity: the permissions.allow entry is gated on the MCP
  registration actually landing (a failed/ownership-refused registration must
  not bless a foreign server) and is rolled back with a failed smoke.
- --status recovers a bearer ONLY from a registration whose URL matches the
  receipt ([C8] everywhere) — never transmits another install's credential.
- Half-removed receipts (zero targets, minted token awaiting deferred revoke)
  FAIL doctor + exit 1 from --status instead of reading vacuously green;
  --status also exits 1 on failed/pending targets and unconverged rotations.
- [X3] stale-target cleanup deferred until AFTER the smoke passes (mint-first
  applies to removals too: a mint/lock failure no longer strands a box that
  had working wiring); stale-remove exit codes checked, not assumed.
- oauth-provider's pre-v38 fallback SELECT keeps the ORIGINAL-schema scopes
  column — a failed permissions projection no longer grandfathers scoped
  tokens to full admin.
- Hook yield guard PARSES settings and requires a live bootstrap-v1 entry for
  THIS event (both carriers) — a repo committing marker-lookalike strings can
  no longer disable the machine-wide capture lane, and unwired events run.
- permissions writers fail closed on policy shapes they don't understand;
  auth list renders through the SAME normalizer the verify path uses;
  isServeOlderThanScopes pins the first scope-aware release (no cry-wolf on
  the next CLI bump); harness receipts shape-validate before consumers
  dereference; rollback bearers are validated before re-registration.
- [X11] lock parity on every remaining path: removeHarness host-removals,
  stale cleanup, codex rollback; runUninstall holds the HOME lock across the
  whole teardown (no mint window between harness removal and rm of
  <home>/bootstrap); --project hook writes carry the [D12] committed-carrier
  events; cross-home user-scope-vs-project double-fire refused where knowable.
- applyHarness --json emits ONLY the JSON document on stdout (prose → stderr).
- Registry prose-bleed swept (a comment's literal typo'd flag would have made
  the typo VALID); TODOS updated: canary+fresh-revoke landed, identity
  comparison + orphan-mint reconciliation filed as residuals.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(release): sync harness docs with the shipped adversarial-review behavior (v0.45.14.0)

/document-release drift pass against the final #4043 diff:
- KEY_FILES.md: drop the stale duplicate src/commands/hook.ts entry left by
  the mid-wave append; the surviving entry now describes the per-event yield
  guard that parses BOTH workspace settings carriers. harness.ts entry gains
  the canary-gated smoke + symmetric rollback (fresh mint revoked on any
  failed smoke), the registration-gated pre-approval, post-smoke stale
  cleanup, the --status exit contract (incl. half-removed receipts), the
  URL-matched bearer recovery, the SCOPES_MIN_SERVE_VERSION pin, apply
  --json stdout-only discipline, and codex config-dir lock parity. hooks.ts
  entry notes the permissions writers fail closed on policy shapes they
  don't understand.
- docs/guides/bootstrap.md: the mint-first bullet states the failed-smoke
  rollback + fresh-mint retirement guarantee; the --status bullet spells out
  the cron exit contract and the install-level --json contract.
- README.md: the bootstrap guide link mentions local harness mode.
- CHANGELOG.md: restore the blank line before the 0.45.12.0 header
  (formatting only; no entry content touched).
- llms bundles regenerated (bun run build:llms; freshness test green).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(release): cross-model doc-review fixes — honest scoping + two filed residuals (v0.45.14.0)

Independent doc-review pass (Claude subagent; codex session init failed on an
unrelated local MCP timeout) against the shipped #4043 diff. Verified 10
findings against the code; applied 8, filed 2:

- bootstrap.md + KEY_FILES.md: scope the --status bearer-recovery URL-match
  claim to the Claude Code lane (the codex managed block is read at the
  receipt-recorded path; its url key is not yet compared — filed in TODOS);
  add the no-install exit-0/plain vs exit-2/--json distinction and the
  unreachable-serve / failed-verify exit-1 trips; document the wider flag
  surface + registrar mode (--url/--port/--force/--name/--no-hooks); state
  that a supplied --token is never revoked by --remove or rotation.
- KEY_FILES.md: permissions-writer clause corrected — the add path fails
  closed on alien policy shapes, removal leaves what it can't read untouched;
  token-mint.ts entry gains the required takesHolders option and TOKEN_ID_RE.
- DEPLOY.md: revoke-by-name hits every same-name token; auth revoke --id +
  the id/scopes columns in auth list are the precise path.
- RESOLVER.md: restore an "install gbrain into this agent workspace" trigger.
- CHANGELOG (factual drift only): damaged permissions rows are reset to a
  clean object, not recovered; scope-display claim narrowed to the verify +
  CLI display paths.
- TODOS.md: filed the codex-lane [C8] URL-match residual and the admin
  dashboard scope-display normalizer residual.
- llms bundles regenerated; guards green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(harness): close the two doc-review residuals — codex-lane bearer ownership + dashboard scope honesty (#4043)

- parseCodexBlockBearer takes an expectedUrl: --status only recovers the
  managed block's bearer when the block's url matches the receipt ([C8]
  parity with the claude lane — two GBRAIN_HOMEs sharing the one user-global
  codex config could otherwise hand install A install B's credential).
- The admin dashboard renders legacy-token scopes through the SAME
  normalizeTokenScopes the verify path uses (NULL = grandfathered full
  access; damaged/deny rows show what the serve actually enforces) instead
  of raw array_to_string.
- Both TODOS residual entries removed (fixed, not filed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(skills): regenerate skills.lock.json — RESOLVER.md + setup/SKILL.md edits from the #4043 wave

The wave's skill-routing updates (harness routing row in RESOLVER.md, the
harness pointer in setup/SKILL.md) landed without the manifest-lock chaser;
CI's check:skills-manifest caught the stale hashes. Full verify suite green
locally (39/39).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 11:51:01 -07:00
Garry TanandClaude Fable 5 6a905a1e5b v0.45.13.0 feat(mcp): truthful surface wave — honest catalog, fail-loud retrieval, per-client starter surface (#4096)
* feat(writer): surface writer_lint findings in the put_page payload (T11/WP6)

put_page's writer_lint block grows from counts-only to an actionable
summary: errors-first top_findings (cap 5, per-finding fix hint, message
truncation), details_truncated, and a by_validator histogram. Contract per
amendment 28: lint ran with zero findings keeps the key present (zeroed);
a lint crash returns {status: 'lint_error'}, distinguishable from lint-off
(key absent).

validators/index.ts exports BUILTIN_VALIDATORS as the single registry
(ENG-12): registerBuiltinValidators, runPostWriteLint, and the new
FIX_HINTS map all derive from it. The payload plumbing lives in
post-write.ts (summarizeWriterLint + writerLintForPutPage) so every
outcome mapping is unit-testable without mocking the op handler.

New test pins the previously-unpinned payload shapes end-to-end on PGLite
plus FIX_HINTS completeness against the registry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(mcp): WP1 honest catalog — gate/scope/bound-aware tools/list + localOnly transport backstop

The advertised list is now exactly what the calling token can use: tools/list
filters per request by token scope, the bound-client fence predicate (shared
with dispatch via opAllowedForBoundClient so list and deny cannot drift), and
the publish gates (hidden while off; read failure hides the gated ops, never
fails the list). localOnly ops are confined to the stdio local pipe by a new
dispatch-layer backstop keyed on transport locality; the legacy bearer
transport also stops listing them. Denials carry the machine-readable
config_key detail grammar.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(mcp): WP2 _meta.retrieval channel + model-visible empty-result block + concept hint (E1)

search/query publish their already-computed retrieval meta (counts, vector
arm, cache, budget, degradation stages) through a per-key _meta side channel;
empty results additionally carry a second text content block so the model
sees the diagnosis in every harness while deployed thin-clients keep parsing
content[0] unchanged. The concept-shaped hint (TODOS P2) rides the same
channel on the search op. Producer isolation: a metaHook failure can no
longer drop handler-emitted keys. Convention doc: docs/protocol/MCP_META_CHANNELS.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(verbs): synthesize compose status + extractive fallback (WP2/T5, E2, ENG-10/19)

Stop dropping runThink's failure signal at the synthesize verb. runThink now
stamps a typed synthesis_status (ok | empty_answer | not_json | no_llm |
model_unusable | llm_error) and catches client.create() throws (429/timeout/
5xx/network) into llm_error instead of crashing the call — explicit-model
AIConfigError (#1698), BudgetExhausted, and AbortError stay hard throws.

Verb precedence (registry refinement): compose failure + non-empty gather ->
extractive fallback (synthesis_status: extractive_fallback; answer digests +
cites ONLY gathered pages via composeExtractiveFallback — empty gather NEVER
produces an answer, ENG-19); compose failure + empty gather -> typed
verbError('unavailable', 'retrieved 0 pages; compose failed: <code>'); no-LLM
stays the [c10] unavailable error regardless of gather. Every success response
now carries additive synthesis_status / pages_gathered / takes_gathered /
warnings; RESPONSE_SCHEMAS extended additively (protocol_version stays 1);
MEMORY_VERBS_v1.md gains the compose-status subsection (+ llms-full rebuild).
The think op inherits the new ThinkResult fields via its existing spread.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(minions): T10 minions visibility — snapshot v2, submit-time queue probes, get_agent_job

WP5 of the MCP truthful-surface wave (amendments 16, 24-27, ENG-13/16/18/20):

- get_status_snapshot schema_version 1->2: additive queue (status counts +
  per-queue depth + oldest_waiting_age_seconds, generalizing the doctor
  oldest-age SQL past embed-backfill) and workers (supervisor liveness via
  pidfile + DB-lock ladder, last_completed_at) sections, each fail-soft to
  {error: 'unavailable'} without failing the snapshot. Thin-client
  `gbrain status` renders the remote payload for workers/queue and degrades
  gracefully against old (v1) servers.
- submit_job/submit_agent attach queue_state from a time-bounded (~1.5s),
  fail-open probe (probeQueueState in supervisor.ts, reusing
  queryWedgeSignals + supervisor DB-lock liveness + worker registry + the
  autopilot pause marker). Warnings fire on dead lane, over-threshold depth
  (GBRAIN_QUEUE_WAITING_THRESHOLD), and migration pause; a probe failure
  degrades to {probe_failed: true} and never errors a paid submission.
- NEW get_agent_job op (scope 'agent', now a first-class Operation scope
  union member; the `'agent' as any` cast on submit_agent is gone):
  clientId required on every transport, fail-closed JSONB ownership WHERE,
  uniform not_found for foreign/missing ids (ErrorCode comment widened),
  trimmed view + claim-order queue_position for waiting jobs.
- computeQueueHealthCheck now returns structured details
  {depth, oldest_age_seconds, worker_alive}; messages unchanged.

No migration DDL in this lane: the wedge index (queue, status, updated_at)
lands with the wave's single migration in another lane; new queries note
the index prefix they will ride.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(search): fail-loud retrieval core — degraded[] stamp, allSettled salvage, minKeep budget, short-TTL degraded cache (WP2/T3)

Lane B of the MCP consumer-feedback wave (amendments 5-8, D6, D14.2,
ENG-2/5/6/7/15, FOV-2):

- types.ts: HybridSearchMeta gains degraded[] (CLOSED exported stage
  vocabulary DEGRADED_STAGES + enumerated DEGRADED_REASONS — raw
  exception text never rides the wire, D6) and retrieved_count
  (pre-budget hit count); token_budget gains truncated.
- hybrid.ts: Promise.all → Promise.allSettled on BOTH the embed fan-out
  and the searchVector fan-out (ENG-15). Salvage semantics: variant
  embed fails → original survives (expansion_partial); ORIGINAL fails
  with variants ok → salvage variant lists, skip cosine re-score
  (expansion_partial + rescore_skipped); all fail → keyword-only
  (embed_unavailable/embed_timeout). Keyword-only-config and image/
  unified branches stamp their degradation too (no silent bypass).
  GBRAIN_SEARCH_SALVAGE=off (env-only, ENG-7) restores all-or-nothing
  embeds + the strict budget wrapper.
- token-budget.ts: packToBudget UNCHANGED (frozen verb consumers);
  enforceTokenBudget gains the minKeep:1 failsafe — first-result-
  exceeds-budget keeps ONE result with chunk_text truncated on a COPY
  (never mutating the shared SearchResult); sub-title-cost budgets keep
  a title-only copy; dropped=N-1 + truncated reported (ENG-2/FOV-2).
- hybridSearchCached: both meta rebuilds become spread-carry (ENG-5) so
  no inner key can silently drop again; hit path stamps cache 'hit'
  (hit-with-offset included); rows lacking the degradation stamp emit
  degraded:[{stage:'cache_prestamp'}] instead of claiming clean;
  degraded-but-embeddable result sets cache with a short TTL (60s,
  D14.2/ENG-6 — total embed outage stays uncacheable by construction).
- mode.ts: KNOBS_HASH_VERSION 15→16 (degradation-stamp epoch).
- telemetry.ts: empty_result rollup keyed by cause (vector_disabled /
  budget_dropped_all / keyword_zero) riding reserved
  (date,'empty_result',cause) rows — zero new DDL; surfaced via
  readSearchStats.empty_results and diverted from call/intent/mode
  aggregates.
- tests: token-budget minKeep flip + direct packToBudget strict-edge
  pins (context_pack-shaped fixture); ENG-15 three-branch salvage suite
  + vector-arm + kill-switch; meta-key-parity (bare ⊆ cached, hit and
  miss); cache_prestamp fixture; short-TTL + null-embedding-skip;
  empty-cause telemetry suite; KNOBS_HASH_VERSION pins updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(mcp): WP3 discovery — complete param schemas, strict/warn arg validation, one schema mapper

T6 — descriptors + strict/warn validation:
- Backfill descriptions on all 36 previously-undescribed params across 24
  non-localOnly ops (search.query / query.query / resolve_slugs.partial
  first), house style with inline examples where a name is guessably wrong.
- CI walker in test/mcp-tool-defs.test.ts fails on any non-localOnly op
  param lacking a non-empty description (localOnly exempt).
- Extract normalizeOptionalParams + validateParams into
  src/mcp/validate-params.ts (call order normalize→validate preserved, doc
  comments verbatim; dispatch re-exports; server.ts second caller updated).
- New config key mcp.strict_params ('warn' default | 'reject'), resolved
  dual-plane (DB > file > warn) once per dispatch. Unknown top-level keys
  (allowlist: _meta, dry_run) warn-collect into _meta.warnings
  [{code:'unknown_param', param, suggestion?}] + a model-visible second
  content block in warn mode; reject mode returns invalid_params with the
  did-you-mean in `suggestion` only — the raw unknown key never reaches
  `message`, the one field persisted to mcp_request_log.error_message.
- Enum membership violations return invalid_params in BOTH modes, naming
  the allowed values (never echoing the submitted value).
- unknown_tool did-you-mean via one shared envelope builder for all three
  deny paths (hidden/nonexistent/localOnly-over-HTTP); candidates are the
  caller-visible surface minus localOnly minus publish-gated ops, so hidden
  names never leak and hidden-vs-nonexistent stays byte-identical (pinned).
- query op's plain "requires either query or image" throw is now
  OperationError('invalid_params', ...).
- serve-http logs 'success_with_warnings' when a result carries non-empty
  _meta.warnings (warn contents never logged) — amendment 13 observability.

T7 — one schema mapper:
- buildToolDefs(ops, {strictParams}): strict emission closes each schema
  with additionalProperties:false and declares the _meta/dry_run
  passthrough keys (D14.1, no clobber of real dry_run params); default
  emission stays byte-identical (both states pinned).
- serve-http ListTools unified onto buildToolDefs with a per-request
  dual-plane strict_params read (restart-free flip); stdio + legacy bearer
  transports resolve once at startup from the file plane (flip needs a
  restart there — deliberate, per plan).
- gbrain --tools-json rebuilt on buildToolDefs additively: legacy
  name/description/parameters keys preserved verbatim, full JSON Schema
  added under a new per-tool `schema` key.

Rider: test/file-upload-engine-context.test.ts now dispatches localOnly
file ops with transport:'stdio' — it pins engine ownership, and the WP1/D7
localOnly backstop (earlier commit on this branch) correctly denies its
old transport-less dispatch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(mcp): WP4 starter surface, per-client ceiling-bounded unlock, request_tools

T8 — per-client surface persistence:
- Migration v127: oauth_clients.surface + surface_set_by (open value
  space, amendment 18) + the ENG-10 wedge index
  idx_minion_jobs_queue_status_updated (queue, status, updated_at);
  mirrored in schema.sql / schema-embedded.ts / pglite-schema.ts and the
  forward-reference bootstrap probe sets of BOTH engines (v121 mask
  class), pinned by test/schema-bootstrap-coverage.test.ts.
- verifyAccessToken gains a NEW top degrade-ladder rung (drop the
  surface columns first, keep the v85 fence column); missingOAuthColumn
  probes BOTH new names (ENG-9). AuthInfo threads surface + surfaceSetBy.
- McpSurface widens to 'verbs' | 'starter' | 'full'. STARTER_OPS is
  composed programmatically (spread of VERB_NAMES — seven verbs, ENG-1 —
  + the FOV-6b fallback daily set from BRAIN_TOOL_ALLOWLIST + the FOV-4
  agent lane + whoami + request_tools) with a provenance note; the
  production-histogram derivation corrects it later. Monotonicity
  verbs ⊆ starter ⊆ full pinned; 'verbs' semantics untouched.
- D2 CEILING: serve-http resolves min(server --surface ceiling, client
  row surface ?? mcp.default_surface_dcr ?? ceiling) PER REQUEST
  (amendment 20); unknown row values ignored with warn-once per client.
  GBRAIN_MCP_FORCE_SURFACE kill switch min()s in on top, NARROW-ONLY
  (FOV-6a), pinned next to the D2 ceiling test.
- gbrain auth rescope-client --surface verbs|starter|full|clear
  (surface_set_by='operator'; 'clear' nulls both) + the admin endpoint
  mirror; EVERY surface mutation (CLI, admin, request_tools persist)
  writes an mcp_request_log operation='surface_change' audit row with a
  raw-object params payload via executeRawJsonb (ENG-8, amendment 32).

T9 — request_tools meta-op (contract-first):
- scope 'read' + mutating + agentCallable (FOV-4 scope carve-out in
  serve-http list + call); listed on starter+full only (D4). No args →
  area-grouped catalog of the ops VISIBLE to the caller (scope, fence,
  localOnly-on-http, publish gates, ceiling — hidden names never leak);
  {tools} → read-only descriptors for the visible subset (D5);
  {surface} → self-persist within the ceiling, denied on operator lock
  (detail 'locked_by=operator') or above the ceiling (detail
  'ceiling=<surface>'), ~5/hour/client rate limit (D14.5),
  pre-migration → {persisted:false, reason:'migration pending'}.
- D9 meta-op carve-out (BOUND_CLIENT_META_OPS) in opAllowedForBoundClient
  so slug-bound clients keep discovery; persist self-enforces its guards.
- Operation.area populated for every non-localOnly op (names
  non-contractual, amendment 22); tool-defs CI walker extended.

Integrator fixes (branch verify gate was red at HEAD): withEnv() for the
lane-B token-budget env tests, lane-F writer-lint comment reworded off
the R2 lint token, publish-gates.ts added to the operations-filter-bypass
allowlist with rationale.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(cli): T15 — empty results name their cause on both CLI surfaces

formatResult's empty branch renders the retrieval degradation (stages +
pre-trim count) captured from either path: the local engine via the
emitResponseMeta twin, or the thin-client envelope via _meta.retrieval.
unpackToolResult stays content[0]-only by contract (D8 skew guard, now
pinned) and extractResponseMeta lifts the envelope meta without erroring
on old servers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(cli): fix generic on unpackToolResult pin (typecheck)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(mcp): T12 expansions — usage reader, advisor starter-fit, usage CLI, TOOL_CATALOG generator

The four T12 expansions over the truthful-surface wave's machinery
(amendments 22-23, 29-30, D12):

- src/core/mcp-usage.ts: the ONE shared reader over mcp_request_log.
  Encodes the row-hygiene rules once (JSON-RPC method rows + ENG-8
  surface_change audit rows dropped; legacy 'tools/call:<name>' prefix
  stripped), windows on created_at (rides idx_mcp_log_time_agent), and
  classifies automation-shaped clients behaviorally (>90% context_pack/
  delta boundary calls — D12; the hook lane is stdio and never logs, so
  there is no name convention to key on).
- E3: advisor collector `mcp-client-fit` — per-client starter fit
  (full-surface client whose 30d distinct-op set fits STARTER_OPS gets
  the exact `gbrain auth rescope-client <id> --surface starter` fix) +
  set-level drift curation (top-used ops missing from STARTER_OPS;
  starter members unused 90d). Remote output redacts client identifiers
  to aggregate counts (amendment 29); dismiss/snooze rides the nag-state
  engine with its own state file; >=10-call alert threshold.
- E4: `gbrain auth clients [--usage] [--days N] [--json]` — per-client
  op-call counts, top ops, last-seen, joined with scopes + surface +
  surface_set_by from oauth_clients; legacy bearer tokens listed
  separately (no per-client surface row to rescope).
- scripts/derive-starter-ops.ts (amendment 23 + D12): proposes the
  STARTER_OPS daily slice from production usage — per-client DISTINCT-op
  sets ranked by client count, automation clients excluded, provenance
  header, BRAIN_TOOL_ALLOWLIST cross-check. Prints only; never edits.
- E6: generated docs/TOOL_CATALOG.md (config-independent, deterministic;
  one section per area; per-op scope/starter/gate columns; non-localOnly
  only) via src/mcp/tool-catalog.ts + scripts/generate-tool-catalog.ts,
  freshness-guarded by scripts/check-tool-catalog-fresh.sh wired into
  `bun run verify` (the METRIC_GLOSSARY pattern).

Tests: test/mcp-usage.test.ts (hygiene incl. legacy prefix + exclusions,
windowing, automation classification), test/advisor-mcp-client-fit.test.ts
(local vs remote redaction, exclusions, drift, snooze lifecycle),
test/tool-catalog.test.ts (determinism, coverage, freshness + CI wiring).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(ops): T14 — surface runbook, honest-catalog metric, TODOS filings, docs pass + llms regen

Observability + operator tail of the truthful-surface wave (amendments
33-36, D10, D14.3, ENG-6/7/8/13):

- docs/operations/mcp-surface-runbook.md: the four operator moves with
  exact commands + expected outcomes (publish-gate flip, surface rescope
  with audit-row verification, strict-params warn→reject flip with the
  named evidence criterion + schema-emission note, STARTER_OPS re-derive),
  the incident levers (GBRAIN_MCP_FORCE_SURFACE narrow-only clamp,
  GBRAIN_SEARCH_SALVAGE=off), the ENG-6 total-embed-outage expectations
  (query cache uncacheable by construction; keyword-only degraded
  results), the honest-catalog metric SQL, and a first-5-minutes
  post-deploy checklist with a ~/.gbrain/smoke-tests.d drop-in snippet.
- Honest-catalog metric (amendment 33): op-level call-time denials the
  tools/list filter should have prevented now log
  status='denied_after_list' instead of 'error' — the inline scope deny
  in serve-http, the publish-gate backstop (detail 'config_key=...'),
  and the bound-client fence OP-level deny (new detail 'fence=op',
  assign-after per ENG-11). Argument-level slug-fence denials carry no
  marker and stay 'error' (D10 carve-out). Classifier
  `isListLevelDenialEnvelope` exported from src/mcp/dispatch.ts; pinned
  by test/denied-after-list.test.ts through real dispatch envelopes.
- Amendment 23 stopgap: the tools/list mcp_request_log row now records
  the listed size as params.tool_count (raw object via executeRawJsonb).
- TODOS.md: eight filings — strict_params reject-flip (P1, named
  zero-success_with_warnings/30d criterion + the pinned default=warn
  test), mcp_request_log retention/pruning (now carries surface_change
  audit + denied_after_list rows), describe_tools (OQ4), page_lint (OQ5),
  named client tiers, per-client token budgets, full list-size telemetry,
  get_job not_found alignment (ENG-13).
- Docs pass (current-state only): MEMORY_VERBS_v1.md surface modes gain
  'starter' + the D2 ceiling semantics; thin-client.md documents the
  full-surface posture (bootstrap pin; stdio has no client row);
  KEY_FILES.md entries updated (surface.ts, dispatch.ts, tool-defs.ts,
  serve-http.ts, advisor cluster) + new entries (validate-params.ts,
  publish-gates.ts, tool-catalog.ts, surface-audit.ts, mcp-usage.ts,
  MCP_META_CHANNELS.md). llms bundles regenerated (bun run build:llms).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(e2e): E5 truthful-catalog invariant — listed means callable, hidden means unknown

The wave's final guard (T13/E5, D10 carve-out, ENG-21 recipe, FOV-4/6c,
amendment 31): for each token class, tools/list then probe every listed
tool and assert no probe returns a LIST-LEVEL denial (publish-gate
config_key=..., bound-fence fence=op, scope insufficient_scope, or an
unknown envelope for an advertised name); invalid_params is acceptable.
Inversely, surface-hidden and localOnly ops return the no-leak
unknown-tool envelope, gate-hidden ops hit the fail-closed config_key
backstop, and fence-hidden ops hit fence=op.

Placement: test/truthful-catalog.e2e-lite.test.ts in the UNIT tree (not
test/e2e/, which runs only under run-e2e.sh on Postgres hosts) — the file
needs no DATABASE_URL, so the invariant runs in every CI unit pass.

Recipe (ENG-21): ONE PGLite engine + ONE real legacy-bearer HTTP server
reused across cells; the OAuth serve-http semantics run in-process through
the exact seams serve-http composes (filterOpsForSurface, hasScope +
agentCallable carve-out, opAllowedForBoundClient, disabledOpsForPublishGates,
dispatchToolCall). mcp.strict_params pinned 'reject' on the DB plane so no
garbage-arg probe can execute a write handler; required params probed with
wrong-typed values; request_tools probed with {surface:'garbage'} (never a
persist); exactly ONE warn-mode case probing a READ op (FOV-6c). Matrix:
scopes {read,write,admin,agent} x surfaces {verbs,starter,full} x gates
{on,off} x bound/unbound — full probe sweep on 3 extreme cells
(admin+full+gates-on, read+starter+gates-off, bound+write+full), list-set
equality + denial-class representative probes on the remaining 48 cells,
the FOV-4 agent-only row (exactly submit_agent/get_agent_job/request_tools),
verbs ⊆ starter ⊆ full monotonicity per token class, the amendment-20
persist→re-list flip (request_tools {surface} persist reflected by the next
per-request resolution, no restart), and a loud <3-minute wall-clock budget.
The full OAuth-server sweep (real HTTP + real tokens) remains the
Postgres-host assertion in test/e2e/serve-http-oauth.test.ts.

Rider fix the guard forced: the legacy bearer transport
(src/mcp/http-transport.ts) listed the four publish-gated ops
unconditionally (tool list built once at startup, no gate filter), so with
gates off — the default — they were listed-but-denied with the config_key
list-level denial: the exact catalog lie this wave exists to end. Its
tools/list now subtracts disabledOpsForPublishGates per request (dual-plane
read, restart-free flip, fail-closed on read failure), matching the OAuth
transport; the in-handler gates stay as the call-time backstop. Verified by
mutation: reverting the fix fails 3 of the new legacy-transport tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(cli): regenerate flag registry for the wave's new auth flags

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: pre-landing review fixes

Ten review findings, all two-way doors:

1. health-indicators error rate no longer counts success_with_warnings /
   surface_change rows as errors (denied_after_list stays counted).
2. visibleOpsForCaller treats the trusted local CLI (remote === false) like
   stdio: localOnly + publish-gated ops stay visible to the operator who can
   actually call them.
3. request_tools dry-run previews no longer consume the persist rate-limit
   budget (denials still exercised; limiter meters actual writes only).
4. normalizeLoggedOperation re-runs the NON_OP_LOG_ROWS hygiene check on
   legacy-prefix-stripped names ('tools/call:tools/list' no longer counts
   as usage).
5. LLM_CALL_FAILED warnings carry a closed-vocabulary class (timeout |
   rate_limited | network | provider_error) instead of raw provider text;
   the raw message goes to stderr. MEMORY_VERBS doc + schema updated.
6. Publish-gate + strict-params config reads are issued concurrently
   (tools/list RTT depth 3 -> 1).
7. resolveEffectiveSurface skips the default-surface config read when the
   clamped ceiling is already 'verbs' (min() cannot go lower).
8. buildQueueDepths / doctor waitingByQueue comments now state the truth:
   the wedge index gives no prefix access for a status-only WHERE; these
   full-scan today.
9. Verb-count comments updated to the seven frozen verbs + starter tier.
10. ALWAYS_INCLUDED_STARTER_OPS exported from surface.ts and consumed by the
    advisor starter-fit collector (which omitted the agent lane, producing a
    perpetual bogus unused-starter finding) and derive-starter-ops.

Also extracts requestLogStatusForResult (src/mcp/dispatch.ts) as the one
request-log status decision serve-http persists — behavior identical, unit
pins land in the follow-up test commit. Behavior pins for fixes 2-5 and 10
ride here so every commit stays green (test/request-tools, test/mcp-usage,
test/think-extractive.serial, test/advisor-mcp-client-fit).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: regression tests for request-log statuses and query invalid_params

Coverage-audit gaps + test hygiene from the pre-landing review:

- requestLogStatusForResult unit pins (all four statuses: success,
  success_with_warnings, denied_after_list, error) in
  test/denied-after-list.test.ts; row-level twins live in the Postgres-host
  e2e (extension filed in TODOS.md).
- query op with neither `query` nor `image` returns the typed invalid_params
  envelope, never internal_error (engine stub — the throw precedes any
  search).
- writeSurfaceChangeAudit fail-open contract: a throwing engine resolves
  false and never throws; the happy path binds the params object raw
  (jsonb discipline).
- parseAuthClientsArgs: defaults, --days bounds (incl. >3650 rejection),
  --usage/--json flags, unknown-flag rejection.
- E5 truthful-catalog wall-clock budget is enforced only under
  GBRAIN_ENFORCE_E5_BUDGET=1 (warn otherwise — machine-load-dependent);
  T0 moves from module load into beforeAll.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: file review-deferred TODOs

Six deferrals from the /ship pre-landing review, grouped by component under
a new truthful-surface-wave section: default-surface memoization on the
tools/call hot path (P2), Postgres-host e2e row-level request-log assertions
(P2), surfaceProjectionDegraded marker for drift-shaped brains (P3), partial
completed-jobs index if snapshot polling gets hot (P3), the master-owned
extract-atoms shard flake (P1, with failure signature), and the eight-item
hygiene dedupe batch as one P3 entry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: adversarial review fixes — fail-closed surface resolution, usage hygiene, wire-safe warnings

Twelve fixes from the cross-model (Codex + Claude) adversarial ship review:

- resolveEffectiveSurface holds the last successfully read default surface
  per process, so a transient config outage can't silently widen a
  NULL-surface client to the ceiling; stale never-throws comment rewritten.
- readClientOpUsage counts only success/success_with_warnings rows — denial
  and error traffic can no longer "use" its way into starter derivation or
  advisor fit findings.
- think/index.ts pushes closed warning codes (QUESTION_EMBED_FAILED /
  CALIBRATION_FETCH_FAILED / TRAJECTORY_INJECTION_FAILED) on the wire; raw
  exception text goes to stderr only (D6).
- enforceTokenBudget's minKeep failsafe slices the title too, so used <=
  budget holds unconditionally; the failsafe now stamps a distinct
  budget_truncated stage (additive vocab) while budget_dropped_all is
  reserved for genuinely-empty strict returns.
- advisor drift arm excludes localOnly ops from starter recommendations
  (mirrors derive-starter-ops).
- legacy bearer transport routes tools/call statuses through
  requestLogStatusForResult — denied_after_list / success_with_warnings
  now feed the amendment-33 metric on both HTTP transports.
- request_tools rejects {surface, tools} together as invalid_params; a
  race-lost persist (0-row UPDATE under a concurrent operator pin) refunds
  its rate-limit token (new RateLimiter.refund, capped at limit).
- health-indicators error rate: surface_change is an OPERATION value, not a
  status — audit rows now excluded from numerator AND denominator via the
  operation column.
- expansion_failed carries reason 'timeout' when the expander timed out.
- resolveStrictParamsMode holds the last-known-good DB mode so a transient
  config outage on a reject-mode server can't re-open the warn grace period
  (+ reset seam for tests).
- get_agent_job caps error_text at 2000 chars (unbounded worker field).

Regression tests: usage status filter, denied_after_list on the legacy
transport (DB-plane-pinned gate), strict-mode last-known-good, both-params
reject, limiter refund semantics, title-slice used<=budget pin.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: file adversarial-review TODOs

Four review-deferred items from the ship-stage adversarial review: atomic
old-surface capture for the request_tools audit row (P2), persist rate-limit
durability across restarts/processes (P3), cancellation for timed-out
submit-time queue probes (P3), and a schema_version union doc for the
status snapshot JSON consumers (P3).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

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

Truthful Surface wave — MCP consumer-feedback fixes. Version train:
VERSION + package.json + CHANGELOG + openclaw.plugin.json +
BOOTSTRAP_FOR_AGENTS.md stamp + regenerated bootstrap templates.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: post-ship documentation sync for v0.45.12.0

Catch the drift the pre-landing (a8f502857) and adversarial (28bac59bc)
fix commits introduced after the wave's T14 docs pass:

- KEY_FILES.md: requestLogStatusForResult (both HTTP transports),
  ALWAYS_INCLUDED_STARTER_OPS (surface + advisor + derive-starter-ops),
  resolveEffectiveSurface / resolveStrictParamsMode last-known-good
  fail-closed behavior, RateLimiter.refund, usage success-only status
  filter + prefix-strip hygiene re-run, advisor drift-arm localOnly
  exclusion, health-indicators error-rate audit-row exclusion.
- mcp-surface-runbook.md: --usage counts successful calls only;
  request_tools persist rate-limit/dry-run semantics; strict-params
  reject posture survives a transient config outage.
- INSTALL.md, mcp/DEPLOY.md, mcp/CLAUDE_CODE.md,
  tutorials/connect-coding-agent.md: the verbs surface is seven verbs
  (context_pack + delta), matching the code snippets in the same files;
  surface enumerations now include starter.
- llms.txt / llms-full.txt regenerated (build:llms chaser).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: doc-review fixes — honest starter count/provenance, scoped _meta claim, DCR-default runbook move

Four gaps from the cross-model documentation review:

- Starter surface is ~26 ops (STARTER_OPS.size), not ~20 — harmonized
  across CHANGELOG, MEMORY_VERBS_v1, KEY_FILES, INSTALL, DEPLOY,
  CLAUDE_CODE, connect-coding-agent (the generated TOOL_CATALOG already
  said ~26).
- CHANGELOG no longer claims the v1 starter set was usage-derived: it is
  the reviewed brain-tool slice + agent lane, re-derivable via
  scripts/derive-starter-ops.ts (matches the FOV-6b provenance comment).
- "every MCP response carries _meta.retrieval" scoped to query/search
  (the only producers of the retrieval key).
- mcp-surface-runbook gains the mcp.default_surface_dcr operator move
  (default for NULL-surface clients, ceiling-bounded, per-request).
- llms bundles regenerated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ci): add --timeout to the derive-starter-ops printed hint (bun-test guard)

The check-bun-test-timeout guard greps scripts/ for bare `bun test`
invocations and matched the console.log hint this wave's derivation script
prints. CI (GNU grep) enforces the \b word boundary the local BSD grep
silently drops, so the gate only fired on the runner. The hint now models
the convention it exists to teach.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 10:36:08 -07:00
Masa d941e9f918 fix(search): disclose the telemetry coverage gap in search stats/tune (#4079)
* fix(search): disclose telemetry coverage gap in search stats/tune

`gbrain search stats` / `gbrain search tune` read from an in-memory,
best-effort telemetry buffer that flushes on a 60s timer or 100-call
threshold (by design — see the module header in
src/core/search/telemetry.ts). Short-lived CLI invocations typically
exit before either trigger fires, so their search calls are silently
dropped; only long-lived processes (serve, MCP stdio/HTTP, jobs work)
are reliably counted. `search stats`/`search tune` printed totals and
"data-driven recommendations" with no hint of this scope, so a CLI-first
user could be tuned against a sample that never included their own
searches.

This is a display-only accuracy fix: no telemetry/flush behavior
changes. Adds a `coverage` disclosure (JSON, additive) and a one-line
human-readable caveat to both subcommands, backed by a single exported
note in telemetry.ts so the two callers stay in sync.

* fix(search): correct coverage wording after review + document KEY_FILES

Codex review of the coverage-disclosure commit found real issues, not
just nits:

- "long-lived processes only" overclaimed — a CLI run that itself
  crosses the 100-call flush threshold before exiting IS captured.
  Reworded to "coverage favors long-lived processes ... a lone
  short-lived CLI search call is typically not recorded."
- The pre-existing "Run a few `gbrain query` calls and re-check"
  advice on a 0-count `search stats`/`search tune` now directly
  contradicts the new disclosure (a single CLI call is exactly what
  tends not to survive the flush). Replaced with guidance that matches
  the caveat (use `gbrain serve` / an MCP session for reliable counts).
- The human-readable caveats were hand-paraphrased at each call site,
  which is how the above wording drifted in the first place. Added
  `TELEMETRY_COVERAGE_CAVEAT` (short form, telemetry.ts) as the single
  literal string every human-output call site now reuses.
- Test assertions were too loose to catch inaccurate wording (only
  checked for the word "coverage" / a non-empty reason string).
  Strengthened to pin the exact caveat string / key phrases.
- Added the KEY_FILES.md entry for this behavior per repo convention,
  regenerated llms.txt/llms-full.txt (no diff — content already
  matched).

* fix(search): address round-2 Codex nits (honest wording + wording pins)

- Soften "captures counts reliably over time" (best-effort telemetry can
  still silently drop a flush) to "is more likely to record counts over
  time (telemetry stays best-effort either way)".
- Add wording-accuracy pin tests that hardcode the expected substance
  independently of the TELEMETRY_COVERAGE_NOTE/CAVEAT imports — importing
  the same constant into both production code and its own test assertion
  cannot catch an inaccurate edit to that constant (exactly how the
  round-1 "long-lived processes only" / missing "jobs work" bug slipped
  through). New tests assert gbrain serve / MCP / jobs work / short-lived
  CLI / the typically-not-never hedge directly, for both the --json
  reason string and the human caveat.
2026-08-14 05:02:42 -07:00
Javier Aldapeandgbrain-contrib 26578a2478 fix(patterns): accept completed child outcome (#4101)
Co-authored-by: gbrain-contrib <gbrain-contrib@example.com>
2026-08-14 04:53:32 -07:00
Garry TanandClaude Fable 5 7fdcd8bd2e v0.45.12.0 feat(claw-test): hermes e2e install harness — runner, real-binary door, CI job, friction diff (#4058)
* chore(guards): allow the public Hermes platform name in tests

The banned entry targeted conflating the public NousResearch agent with
private deployment names. gbrain now documents and tests against the
public platform (README hero, claw-test runner, install door e2e), so
the public name is legal in tests; private fork names remain banned.
Drops the three now-inert allowlist entries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(claw-test): hermes runner, live staging + success oracles, friction diff

HermesRunner (hermes -z one-shot, HERMES_BIN > which hermes, allowlist
env with HERMES_HOME + OPENROUTER_API_KEY delta). Live mode now stages
the scenario before the agent turn (fresh-install: brain + routing stub
+ init; upgrade: seed-first) and verifies outcomes after it: doctor
must parse and report healthy/warnings, scenario-declared query +
files_exist oracles are enforced for every kind, and upgrades use a
non-mutating schema-version probe that must reach LATEST_VERSION.
Missing upgrade seed dumps fail loudly in BOTH modes (a silent skip
false-greened the upgrade lane). Bare gbrain in live runs resolves
through a per-run PATH shim; when gbrain itself runs under the bun
runtime the harness synthesizes a launcher back into cli.ts instead of
handing children the bun binary.

gbrain friction diff --base/--compare: identity is (kind, phase,
digit-collapsed 80-char prefix); severity compares as a per-severity
distribution (integer proportion test) so redistribution and
delight-to-friction flips always surface; run start/end phase markers
carry agent + scenario for agent-name resolution.

Hardening from the adversarial gate: every harness child runs under a
wall-clock timeout with process-group kill + exit-fallback settle;
scenario names and declared brief/brain/seed paths are confined to the
scenario dir; child friction merges require a regular file, cap size,
and keep only valid JSONL lines; crashed runs stamp a non-zero end
marker; GBRAIN_* routing vars are scrubbed from child env; agent
stdin closes at spawn; argv agent/scenario values are charset-guarded.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(hermes): real-binary install door e2e + generic workspace compat

Door e2e registers this checkout's gbrain into a hermetic Hermes home
via the real CLI (single --env flag with multiple values, piped
confirm, enabled:true + mcp test as the success discriminators), the
direct-YAML surface, and a paid one-shot smoke turn proving MCP recall
of a seeded synthetic fact with a NO-GBRAIN-TOOL negative control.
Triple-gated (opt-in env + resolvable binary + non-empty anthropic key)
so it can never burn tokens by accident; anthropic-only auth because a
second visible provider key mis-routes hermes provider auto-detection.
Helpers copy exactly ONE provider key from the operator's env file,
never the whole file, and scrub all provider keys from child env.

workspace-generic-compat pins the documented any-repo-with-a-workspace
install flow (detection tier, scaffold additivity, resolver health) on
a generic fixture; the Hermes-behavior proof lives in the door test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci: label-gated hermes-door job + e2e hermeticity scrub

hermes-door provisions a pinned Hermes release: installer digest
verified before execution, payload tag+commit flags ASSERTED post-
install via rev-parse (an installer that ignores unknown flags can
never run unpinned upstream code next to secrets), secretless install
step, loud-fail preconditions, zero-pass-refuses-green, evidence
scrubbed three ways before upload, and unconditional credential
cleanup for self-hosted-runner safety. real-agent-e2e gains the door
file + opt-in env. run-e2e.sh scrubs HERMES_* alongside OPENCLAW_*;
e2e-test-map narrows claw-test core changes to their e2e suite.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: hermes + openclaw MCP guides, CLI pin notes, harness reference updates

Per-client docs for Hermes (observed-behavior guide incl. flag-order
and multi-key gotchas) and OpenClaw; HERMES-CLI-PIN records every
pinned CLI behavior + the CI pin posture. README MCP table rows,
INSTALL_FOR_AGENTS hermes block, TESTING/KEY_FILES current-state
rewrites (two runners, oracle semantics, diff identity), TODOS closure
(hermes runner done, friction diff shipped, follow-ups filed) and the
llms bundle regenerated in the same commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: release sync — verb-count drift, hermes link, discovery rows, harness knobs

Cross-referencing the diff against every .md surfaced drift beyond this
wave: the memory-verbs surface prose still said five verbs (the frozen
protocol grew context_pack + delta additively), docs/INSTALL.md linked
a wrong Hermes repo and missed the new HERMES/OPENCLAW per-client
guides, the door-suite doc pinned a tool COUNT that tracks the op
catalog, the friction protocol skill missed the diff subcommand, and
the claw-test KEY_FILES entry lacked the harness env knobs. Comment
counts in heavy-tests.yml corrected (three triggers; four door tests).
llms bundle regenerated in the same commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci: quote inner expansion in evidence-scrub path strip (shellcheck SC2295)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: re-bump to v0.45.12.0 (user-pinned past the contested 0.45.11.0 slot)

Two sibling PRs already claim 0.45.11.0; pinning one slot higher avoids
a second merge-race re-bump. All version locations move together:
VERSION, package.json, CHANGELOG entry header, openclaw.plugin.json,
bootstrap runbook stamp, regenerated template stamp, CLAUDE.md example
cell, llms bundle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 18:22:08 -07:00
Garry TanandClaude Fable 5 6411150071 v0.45.11.0 feat(bootstrap): OOBE hand-off (own the brain + cold-start) + DX polish waves (#4047)
* feat(bootstrap): TTY DX exploration harness + Krug onboarding fix wave

Add a real-PTY exploration harness and land 16 verified "Don't Make Me
Think" fixes on the paste-in install experience for Claude Code and Codex.

Harness:
- test/helpers/tty-harness.ts — spawns any CLI (gbrain/claude/codex) under a
  real pseudo-terminal (Bun terminal: spawn), timestamps every output burst,
  and turns silence windows into a measurable stall report. Hermetic; pure
  helpers unit-tested in test/tty-harness.test.ts.
- scripts/dx-explore.ts — drives the fresh-user funnel (help / init / real
  claude-install / real codex-install / manual drive mode), writing
  transcripts to .context/dx-runs/ (gitignored).

Fixes (all adversarially verified against the code first):
- Keyless bare `gbrain init` completes in keyless mode instead of exit 1;
  multi-key non-TTY auto-picks the canonical default; typo stays fail-loud.
- Provider picker probe-gates ollama (daemon-up != model-pulled) and offers
  an explicit "continue keyless" option that is the bare-Enter default.
- Fresh-brain init prints one schema-setup line instead of ~240 migration
  names (GBRAIN_MIGRATE_VERBOSE=1 restores detail).
- Init epilogue: memory-verbs funnel is last-on-screen; skills advisory
  compacted for init; Mod Status trimmed.
- PGLite live-serve lock error names the fix (close the agent session).
- Mode-picker banner interpolates the applied mode; expansion-key gate is
  Anthropic/OpenAI/Google, not OpenAI-only.
- Missing `claude` binary skips MCP but still installs hooks; honest copy.
- Foreign MCP-registration removal targets the conflicting scope and fails
  loud if it does not land.
- Upgrade marker compares the running binary to latest and self-spawns via
  execPath, so a current/newer binary no longer nags from a stale cache.
- interview --set/--skip after --confirm warns it voided the confirmation.
- init --help matches behavior; init --supabase fails loud on non-TTY.
- Provider capabilities attributed per provider across README / runbook /
  questions bank / bootstrap.md.
- First-run tour: restart-first, prompt 3 true on day one, withheld on FAIL;
  README gives Codex the same scripted magic moment.
- Empty-brain "0 takes" onboard nudge suppressed.
- Broken settings.local.json aborts the hooks write fail-closed instead of
  silently dropping the user's permissions.

Regenerated cli-flag-registry.generated.ts and llms-full.txt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(bootstrap): second DX polish wave — clean the success screen + honest copy (F17-F21)

Follow-up to the DX fix wave, closing the top-5 remaining gaps the scorecard
flagged (all human-facing polish, not survival):

F17 — machine markers no longer leak to humans:
- verify report drops the `[D3.6]` plan-tag from the first_run_tour detail.
- the raw `UPGRADE_AVAILABLE <cur> <latest>` marker line prints ONLY on a
  non-TTY stderr (parsers still get it); an interactive human sees just the
  "gbrain X -> Y available" sentence.
- per-migration "what changed" notices (v123/v124, incl. the #2704 ref) are
  suppressed on a FRESH-install replay via a module quiet flag; upgrades still
  narrate. (GBRAIN_MIGRATE_VERBOSE=1 restores them.)

F18 — one obvious next action on the init success screen: the memory-verbs
demo is the single "→ Do this next" hero, last on screen; import/migrate/doctor
collapse into one terse "More:" footer; the graph block only shows for a
non-empty brain.

F19 — README "moment it clicks" is now the genuine cross-session brain
round-trip (remember → restart → recall), explicitly distinguished from the
identity-file recall, on both the Codex and Claude Code paths.

F20 — the compact init skills advisory is human-voiced (no `[AGENT]`
stage-direction on the human-facing success screen; the mode-picker's
agent-directed block stays gated to the non-TTY channel).

F21 — time promise reconciled: headline is ~15 min (personal-agent path) /
~30 min (always-on OpenClaw/Hermes); the runbook's search-mode line no longer
claims "balanced" when keyless applies "conservative". README hooks copy says
"on by default, with an opt-out" to match the runbook.

Regenerated llms-full.txt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(bootstrap): address two-model adversarial review of the DX wave

Fixes the regressions the 5-specialist + red-team + Claude/Codex adversarial
pass found in the F1–F21 changes, each with a test:

- Keyless upgrade hint pointed at `config set embedding_model`, which config.ts
  hard-refuses as a schema-sizing no-op — now names the working re-init recipe
  (`gbrain init --force --pglite --embedding-model <id>`), zero-key AND multi-key
  paths.
- Multi-key TTY picker offered "continue keyless" but the caller aborted on it —
  now honors keyless like the zero-key path.
- Detached update-refresh spawn used a `/gbrain$/` basename check that misfires
  for a renamed/official-named compiled binary (`gbrain-darwin-arm64`) and
  prepends the /$bunfs entrypoint — now detects dev-vs-compiled by the runtime
  basename (bun|node) so the refresh always runs.
- `bootstrap status` reported the wire phase "done" on a hooks-only receipt
  (host CLI missing at wire time) — now "partial" with a re-run hint, so a
  resuming agent doesn't trust a false complete.
- Post-repair MCP mismatch re-verifies and aborts instead of blessing a
  registration a racing writer may have re-claimed.
- probeOpenAICompat's abort timer now spans the body read (was cleared before
  it), so a stalled `/v1/models` body can't hang init past the 1s cap.
- Centralized the 4-copy stale-cache upgrade predicate into
  `pendingUpgradeVersion`; UPGRADE_AVAILABLE gains a GBRAIN_FORCE_UPGRADE_MARKER
  override for PTY-based agent harnesses.
- Mode picker's expansion-key gate adds GEMINI_API_KEY; picker prompt is
  article-aware ("an embedding" / "a chat"); dead `!brainEmpty` clause removed;
  migrate.ts try/finally widened + stamp failures named in quiet mode.
- DX harness: credential copies scrubbed even on SIGINT/interrupt (+chmod 600),
  child process TREE reaped on teardown, advisory made fail-open, KEY_MAP typed
  as a literal union.

New tests: migrate quiet-replay, self-upgrade pending predicate + negative
cache cases, bootstrap 127/scoped-remove/broken-settings dispatch, interview
invalidation flag, verify tour-withheld-on-FAIL, init keyless/supabase/multi-key,
init-nudge branches, ai-probes model parsing. Regenerated flag registry +
template-repo.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* v0.45.8.0 fix(bootstrap): onboarding DX polish wave (F17-F21) + review fixes

DX fix wave on the paste-in install/first-run experience for Claude Code and
Codex, driven by a new real-PTY exploration harness. Keyless init completes
instead of erroring, the migration wall collapses to one line, the success
screen leads with one action, and the "magic moment" copy points at the genuine
cross-session round-trip. Full detail in CHANGELOG.

Version trio + openclaw manifest + runbook stamp bumped to 0.45.8.0; CHANGELOG
release entry; TODOS onboarding-DX follow-ups filed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(bootstrap): OOBE hand-off — you own the brain, cold-start is skill #1

A working install now ends by making the two facts that matter actually land:

- `gbrain bootstrap verify` prints (and returns as `handoff` in --json) an
  ownership block — the actual private-repo URL with what owning it means
  (read it, `gbrain bootstrap attach` on machine two, delete it and the brain
  is gone), or the local-only variant pointing at `gbrain bootstrap repo` —
  followed by the ONE next action: run the cold-start skill (Gmail/calendar/
  contacts via ClawVisor, an OAuth vault so the agent never holds raw tokens;
  or offline archives), one consented phase at a time. Withheld on FAIL like
  the tour; shape stays unconditional for machine consumers.
- cold-start ships in the downstream bundle (61 skills): its plugin exclusion
  ("host onboarding flow") predated the v0.45 personal-agent bootstrap and is
  deliberately reversed — the paste-in audience is exactly who day-one
  onboarding is for. It now LEADS the recommended set (ahead of book-mirror:
  every flagship skill only becomes magical once the brain holds the user's
  real life).
- New drift guard: every recommended slug must be scaffoldable from the
  plugin bundle — recommended-but-unscaffoldable is a dead-end CTA and now
  fails the suite.
- Runbook Hand off rewritten around the two must-land facts + the on-the-spot
  cold-start offer; README's Codex and Claude Code paths carry the same two
  follow-ups.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* v0.45.10.0 feat(bootstrap): the OOBE hand-off release

Version trio + runbook stamp + template tree to 0.45.10.0; CHANGELOG entry;
llms bundles regenerated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(ci): stop memory-verbs-conformance leaking a fake-keyed gateway into shard-mates

The deterministic-embedder helper configures the MODULE-GLOBAL gateway with a
fake OpenAI key; the file's afterAll never reset it. The bunfig preload's
per-test restore only fires when the gateway is UNCONFIGURED, so the fake-keyed
config persisted for every later file in the shard process — turn-context's
corpus writes then embedded against real OpenAI and 401'd (CI shard-8 failure;
shard re-binning from this branch's new test files exposed it).

Fix both sides: conformance's afterAll now resetGateway()s back to the preload
baseline and nulls both test transports; turn-context's beforeAll does the same
defensively so it stays hermetic regardless of shard composition.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 15:35:05 -07:00
testandClaude Fable 5 ac402f55f8 v0.45.10.0 fix: community fix-wave 2 — 21 fixes from 9 contributors + maintainer (search completeness, sync safety, doctor honesty)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 12:18:13 -07:00
test cf3527a40f test: conform the #3762 rider to test-isolation rules R3/R4 (engine in beforeAll, afterAll disconnect) 2026-08-13 12:18:13 -07:00
test e2b2819e63 chore: regenerate CLI flag registry after wave-2 assembly 2026-08-13 12:18:13 -07:00
testandGarry Tan 2ae5d60b98 fix(doctor): distinguish a damaged PGLite store from a broken runtime (#2674) (#3569)
Wave-assembled from PR #3569 by @time-attack.

Co-Authored-By: Garry Tan <garrytan@gmail.com>
2026-08-13 12:18:13 -07:00
testandGarry Tan bd4c976a85 fix(sync,webhook): consume deferred link extraction above the size gate (#2849) (#3561)
Wave-assembled from PR #3561 by @time-attack.

Co-Authored-By: Garry Tan <garrytan@gmail.com>
2026-08-13 12:18:13 -07:00
testandGarry Tan 9b9bd8b241 fix(autopilot): stop the drain-worker self-deadlocking at concurrency=1 (#2050) (#3559)
Wave-assembled from PR #3559 by @time-attack.

Co-Authored-By: Garry Tan <garrytan@gmail.com>
2026-08-13 12:18:13 -07:00
testandGarry Tan fd9bb12b42 fix(doctor,upgrade): warn brains pinned to a provider shutting down 2026-09-04 (#3548)
Wave-assembled from PR #3548 by @time-attack. Composition conflicts vs wave
item #3993 resolved: EmbedResult keeps both chunkless_pages_healed and the
PR's lock_skipped; KEY_FILES.md keeps the #3993 embed entry and takes this
PR's migrate-embeddings entry.

Co-Authored-By: Garry Tan <garrytan@gmail.com>
2026-08-13 12:18:13 -07:00
testandTime Attakc 5bd2c51053 fix(search): fold detail into the query-cache key (#3515) (#3544)
Wave-assembled from PR #3544 by @time-attack.

Co-Authored-By: Time Attakc <89218912+time-attack@users.noreply.github.com>
2026-08-13 12:18:13 -07:00
testandTime Attakc 189bf856ee feat(oauth): accept token_ttl_seconds at DCR, clamped to admin policy (#2179) (#3456)
Wave-assembled from PR #3456 by @time-attack.

Co-Authored-By: Time Attakc <89218912+time-attack@users.noreply.github.com>
2026-08-13 12:18:13 -07:00
testandGarry Tan 735dec83b7 fix(extract,cli,pricing): link-aware timeline delimiter, dispatchable backfill, zerank-2 rerank pricing (nwf2) (#3280)
Wave-assembled from PR #3280 by @time-attack.

Co-Authored-By: Garry Tan <garrytan@gmail.com>
2026-08-13 12:18:13 -07:00
testandRerankerGuo fd0e371d5b fix(sync): acknowledge auto-skipped failures (#3829) (#3891)
Wave-assembled from PR #3891 by @RerankerGuo. Conflict resolution: KEY_FILES.md
kept master's newer migrate-engine/import-file/sync entries; the PR's 3-state
machine wording re-applied onto master's sync-failure-ledger entry.

Co-Authored-By: RerankerGuo <121015044+RerankerGuo@users.noreply.github.com>
2026-08-13 12:18:13 -07:00
testandRerankerGuo 136fc109c1 fix(doctor): parse flow-style skill triggers (#3887) (#3888)
Wave-assembled from PR #3888 by @RerankerGuo. Conflict resolution: KEY_FILES.md
check-resolvable/skill-frontmatter/skill-trigger-index entries taken as the
PR's current-state prose; the PR's stale pre-fold 'doctor.ts extension' entry
dropped (master already folded it into the main doctor entry).

Co-Authored-By: RerankerGuo <121015044+RerankerGuo@users.noreply.github.com>
2026-08-13 12:18:13 -07:00
testandRerankerGuo 1243a7d3bb fix(migrate): preserve cross-source link targets (#3859) (#3876)
Wave-assembled from PR #3876 by @RerankerGuo. Conflict resolution: re-applied
the to_source_id fix (copyPageLinksToTarget) onto master's rewritten
migrate-engine.ts link-copy loop; KEY_FILES.md kept master's newer
import-file/sync entries and merged the PR's to_source_id prose + test pin
into the migrate-engine entry.

Co-Authored-By: RerankerGuo <121015044+RerankerGuo@users.noreply.github.com>
2026-08-13 12:18:13 -07:00
testandDov Stern 44eea64084 put_page: report push state honestly instead of implying it via committed (#3936)
Wave-assembled from PR #3936 by @dovstern. Conflict resolution: kept master's
resolveRepoRoot() block AND the PR's exported currentBranch in
src/core/brain-repo-durability.ts. Adaptation: the new serial test now writes
the simulated push log under $GBRAIN_HOME/.gbrain (CX2-8 parent-dir semantics
landed on master after the PR's base).

Co-Authored-By: Dov Stern <dovstern@users.noreply.github.com>
2026-08-13 12:18:13 -07:00
testandDov Stern 45bd04ff9f sources harden: resolve _brain-filing-rules.json per repo instead of always using the bundled copy (#4018)
Wave-assembled from PR #4018 by @dovstern. Conflict resolution: import-block
union in src/core/brain-repo-durability.ts (master's gbrain-home/execution-env
imports kept, PR's loadFilingRules import + resolveFilingRules threading
re-applied onto master's shape). Rider: test renamed to
filing-rules-resolution.serial.test.ts (mutates HOME).

Co-Authored-By: Dov Stern <dovstern@users.noreply.github.com>
2026-08-13 12:18:13 -07:00
testandmasashiono0611 8a626999f0 fix(embed): detect chunkless pages with non-empty content in embed --stale (#3993)
Wave-assembled from PR #3993 by @Masashi-Ono0611. Conflict resolution:
KEY_FILES.md embed.ts entry unioned (PR's chunkless prose + master's newer
keyless-brains tail). Semantic fix: master's keyless clean-refusal return in
runEmbed gained the PR's required chunkless_pages_healed field.

Co-Authored-By: masashiono0611 <masashi.ono.0611@gmail.com>
2026-08-13 12:18:13 -07:00
testandClément Barberousse 033029f25d feat(claude-cli): list the Claude 5 models the CLI already serves (#3976)
Wave-assembled from PR #3976 by @clement0909472. Rider: four toContain pins
(claude-fable-5, claude-opus-5, claude-opus-4-8, claude-sonnet-5) in
test/claude-cli-recipe.test.ts.

Co-Authored-By: Clément Barberousse <clement.barberousse.pro@gmail.com>
2026-08-13 12:18:13 -07:00
testandDov Stern a729ca8a8c sources: list shows unset federation distinctly from explicit isolated (#3940)
Wave-assembled from PR #3940 by @dovstern.

Co-Authored-By: Dov Stern <dovstern@users.noreply.github.com>
2026-08-13 12:18:13 -07:00
testandGBrain Contributor 5087507de0 fix(autopilot): ignore foreign PIDs in stale locks (#2503) (#3860)
Wave-assembled from PR #3860 by @javieraldape.

Co-Authored-By: GBrain Contributor <contributor@example.com>
2026-08-13 12:18:13 -07:00
testandBo ed6e4e3219 fix(import): preserve table/constraint names in error-summary grouping (#3841)
Wave-assembled from PR #3841 by @bo-developing.

Co-Authored-By: Bo <bo.developing@gmail.com>
2026-08-13 12:18:13 -07:00
testandJon McCutchen f8b0ececcb fix(write-through): honor the page's recorded source_path instead of re-deriving <slug>.md (#3782)
Wave-assembled from PR #3782 by @JonMcCutchen. Rider-check: verified the
non-default pageRoot join (repoPath/.sources/<sourceId>) matches how
pages.source_path is recorded (source-root-relative via importFile's
relative(dir, filePath)); no mismatch, no change needed.

Co-Authored-By: Jon McCutchen <jmmccutchen1@gmail.com>
2026-08-13 12:18:13 -07:00
testandAustin Wilhite ca260baaaa fix(cycle): extract_atoms writes atoms outside the chunk/embed pipeline, making every atom invisible to search (#3762)
Wave-assembled from PR #3762 by @awilhite. Rider: PGLite round-trip chunk-count
assertion in test/extract-atoms-chunk-embed.test.ts (verified fails without the
src change).

Co-Authored-By: Austin Wilhite <austinw80@gmail.com>
2026-08-13 12:18:13 -07:00
testandAlexey 0c485415a5 fix(export): scope tag + raw-data sidecar reads to the page's own source (#3755)
Wave-assembled from PR #3755 by @alexey-metaengage.

Co-Authored-By: Alexey <alexey@metaengage.ai>
2026-08-13 12:18:13 -07:00
testandmasashiono0611 0cfedd026d fix(agent): resolve the brain source at submit time instead of hardcoding the seed default (#3647)
Wave-assembled from PR #3647 by @Masashi-Ono0611.

Co-Authored-By: masashiono0611 <masashi.ono.0611@gmail.com>
2026-08-13 12:18:13 -07:00
Javier AldapeandSofía González b92cc967df fix(search): bucket rerank budget failures (#3628) (#4002)
Co-authored-by: Sofía González <sofiagonzalez@Sofias-MacBook-Air.local>
2026-08-13 07:01:12 -07:00
MasaandClaude Sonnet 5 3eccd4ccd6 v0.45.9.0 fix(bootstrap): preview source_id + create brain/ eagerly in hooks phase (#4064)
* fix(bootstrap): preview source_id + create brain/ eagerly in hooks phase

`bootstrap render`/`hooks` never told a human what source_id the
workspace expects until `verify` (the only engine-holding phase) ran.
A human who hand-registered a source before that point would guess an
"intuitive" name, hit an FK error on the first `verify` roundtrip (the
guessed id has no `sources` row), then hit `overlapping_path` on the
retry (their first guess still claims the same brain/ dir) — three
round trips to land the right id.

`hooks` is the last ENGINE-FREE phase before `verify`, and already
knows both the manifest's current source_id and the workspace path, so
it now:
  - creates `<ws>/brain` eagerly (idempotent mkdir), removing the
    manual-mkdir step before `git init && sources add`
  - prints the exact `gbrain sources add <source_id> --path <brain>`
    command
  - previews the collision-fallback id verify would derive
    (`workspace-<hash>`) — a pure function of the workspace's real
    path, so it needs no DB lookup and is safe to preview engine-free

The collision-fallback derivation itself is unchanged; it is now
factored into an exported `deriveWorkspaceSourceId()` in verify.ts so
both call sites (the new hooks preview and the existing
`resolveSourceIdCollision`) share one formula instead of two copies
drifting apart.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016JNV9ABwb32DZEwvo7P4ay

* fix(bootstrap): --force the preview command; quote paths; fix runbook

Codex review round 2 caught three issues with the source_id preview
added in the prior commit:

- The printed `gbrain sources add <id> --path <brain>` command failed
  immediately on a pristine bootstrap: the brain/ dir this phase just
  created is empty (no git history), so `sources add --path` fail-fasts
  as `not_a_git_repo` (#2707). Fixed by appending `--force` — the same
  sanctioned opt-in `test/bootstrap-verify.serial.test.ts` already uses
  to register a brand-new brain/ before any content exists
  (`addSource(engine, { id: 'workspace', localPath: ..., force: true })`).
  Safe here specifically because brainDir is the fixed
  `<workspace>/brain` path this phase just created, not an arbitrary
  user path.
- brainDir was interpolated unquoted; a workspace path containing a
  space broke the printed command. Added a local
  `shellQuoteForDisplay()` (mirroring the existing private `shellQuote`
  already duplicated in hooks.ts / sources-ops.ts / connect.ts).
- The dispatcher test only pattern-matched the collision-fallback id's
  shape (`workspace-[0-9a-f]{8}`) instead of pinning exact equality
  with `deriveWorkspaceSourceId()`, so preview/verify drift could pass
  silently. Now asserts exact equality, plus a new test for the space-
  quoting fix.

Also corrects BOOTSTRAP_FOR_AGENTS.md's runbook step 5, which claimed
skill scaffolding "registers `brain/` as the workspace source" — no
code path does this automatically (confirmed by grep); the step now
points at the `hooks` phase's actual preview + --force command instead
of telling the installing agent there is "nothing to judge" on a step
that silently never ran.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016JNV9ABwb32DZEwvo7P4ay

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 06:50:06 -07:00
Masa 154814b095 fix(bootstrap): guard mutating subcommands against --help side effects (#4065)
`gbrain bootstrap <subcommand> --help` (a help token AFTER the subcommand
name, e.g. `gbrain bootstrap uninstall --help`) fell through into the
subcommand's own arg parsing instead of printing help, since none of the
mutating handlers (repo/hooks/verify/attach/uninstall/render/interview)
checked for --help/-h/help themselves. `uninstall --help` ran a real
uninstall; `repo --help` created a real private GitHub repo; etc.

Add a SUBCOMMAND_HELP usage map plus a pre-dispatch hasHelpToken() guard in
runBootstrap so a help token anywhere in the subcommand's args short-circuits
before any lock/runner/engine/handler call. Bare `help` (no dashes) is also
recognized, except for `interview` (its --set KEY value free-text answers
could legitimately be the literal word "help").

New test/bootstrap-subcommand-help.serial.test.ts arms fixtures so the real
operation would reach its side effect if the guard were removed (an
already-rendered workspace for render/hooks/attach, an operational verify
config, an isolated uninstall home with a real receipt-tracked file, a fresh
interview workspace) and asserts nothing mutates.
2026-08-13 06:19:20 -07:00
MasaandClaude Fable 5 9b720b04af fix(doctor): honor the recorded pid_file in supervisor_singleton check (#4006)
* fix(doctor): honor the recorded pid_file in supervisor_singleton check

`gbrain doctor`'s `supervisor_singleton` check (#1849) compares the local
pidfile holder against the queue-scoped DB lock holder. It read
`readSupervisorPid(DEFAULT_PID_FILE)` unconditionally, even though the
supervisor's own 'started' audit event already records the pid-file path
actually in use (`this.opts.pidFile`). A supervisor launched with a custom
`--pid-file` (e.g. a launchd-managed deployment) would then get a false
"singleton mismatch" warning against its own healthy, single instance,
because the pidfile doctor read was never the one the supervisor wrote.

- doctor.ts now prefers `lastStarted.pid_file` when present, falling back
  to `DEFAULT_PID_FILE` for events that predate the field.
- supervisor.ts resolves `pid_file` to an absolute path at emit time (the
  only cwd context in which a relative `--pid-file` is meaningful), so a
  later reader running from a different cwd doesn't misresolve it. The
  process's own internal pidfile guard/read/write paths are untouched.

The DB lock (`gbrain_cycle_locks`) remains the sole singleton authority per
#1849 — this only corrects which pidfile the diagnostic display reads.

Added test/doctor-supervisor-singleton-pidfile.test.ts covering the fixed
path, the still-mismatching absent-pidfile case, and a source-grep pin for
the compatibility fallback.

* docs(comments): correct pid_file fallback rationale and drop unsupported claims

The 'started' audit event has carried pid_file since the supervisor's
introduction, so the fallback comment no longer claims a predate case;
DEFAULT_PID_FILE is env-overridable (GBRAIN_SUPERVISOR_PID_FILE), so the
comments stop calling it HOME-derived; and the custom --pid-file scenario
is now described as an example rather than asserted as common.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0126D3zLWL5RE3CVxnPANiiU

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 06:02:23 -07:00
Masa e8785c1ab1 fix(dream): scope the patterns phase to the cycle's resolved source (#1586) (#4034)
#1586 threaded the cycle's resolved source through the synthesize phase so
subagent-written pages land in that source's (source_id, slug) rows. The
patterns phase was not updated and kept the pre-#1586 shape: it stamped a
literal 'default' on every collected ref and compared the reverse-write path
against a literal 'default'.

On a per-source cycle that files the page against the wrong source. The row is
created under 'default' (the child had no source_id to scope its put_page
calls) while the reverse-write drops the file into the named source's checkout,
because source_id === 'default' selects the brainDir/<slug>.md branch and
brainDir IS that source's checkout. Row and file then disagree about which
source owns the page — what doctor reports as multi_source_drift.

Applies the same threading synthesize.ts already uses: PatternsPhaseOpts gains
sourceId, cycle.ts passes cycleSourceId at the patterns call site the way it
already does for synthesize, the child carries SubagentHandlerData.source_id,
and reverseWriteRefs takes the cycle source as its native source. Unset stays
'default', so unscoped callers are unchanged.
2026-08-13 05:51:48 -07:00
arisgysel-designandarisgysel-design 638dd0d247 fix(autopilot): reopen targeted remediation slots (#4046) (#4067)
Co-authored-by: arisgysel-design <arisgysel-design@users.noreply.github.com>
2026-08-13 05:44:16 -07:00
Masa 697016f69d fix(bootstrap): Gate 2 checks only the active gh account, not every registered one (#4063)
* fix(bootstrap): Gate 2 checks only the active gh account, not every registered one

createPrivateRepo's Gate 2 ran bare `gh auth status` and treated any
non-zero exit as "not authenticated". That command aggregates every
registered account across every host and exits 1 if even one of them
has auth issues — so a stale, unused, expired account (or one on an
unrelated GitHub Enterprise host) false-blocks `gbrain bootstrap repo`
even while the actual active account works fine.

Gate 2 now scopes the check with `--hostname github.com` (this flow is
already github.com-only end to end: parseGithubOwnerRepo, the
repo-create URL fallback, etc.) and, when the installed `gh` supports
it, `--active` as well (added in cli/cli v2.57.0 — confirmed present at
v2.57.0 and absent at v2.56.0 by diffing status.go across tags on
cli/cli). Support is detected from the `gh --version` output Gate 1
already captures, so an older `gh` falls back to the host-scoped bare
form instead of hard-failing on an unrecognized flag.

Verified `gh auth status --active`'s semantics directly against
cli/cli's pkg/cmd/auth/status/status.go: passing --active skips the
per-host loop over non-active accounts entirely (`if opts.Active {
continue }`), so only the active account's entry can affect the exit
code.

* fix(bootstrap): regenerate flag registry for the new gh --active/--hostname literals

The prior commit's Gate 2 change added the string literals `--active` and
`--hostname` inside src/core/bootstrap/repo.ts (both in the gh argv and in
comments). src/commands/bootstrap.ts statically imports repo.ts, and
scripts/generate-flag-registry.ts scans one level of relative imports from
each CLI_ONLY case block to build the committed, freshness-pinned
src/core/cli-flag-registry.generated.ts (#2185) — so the 'bootstrap' entry
was stale relative to a fresh `bun run build:flag-registry` run, failing
test/cli-flag-validation.test.ts's freshness guard in CI.

Regenerated via `bun run build:flag-registry`; the only change is 'bootstrap'
gaining '--active' and '--hostname' alongside its existing ~70 entries. This
is the generator's documented, deliberately over-inclusive behavior (accepting
an unused flag is the pre-#2185 status quo) — neither flag is
security-sensitive or read from user input; both are hardcoded in the `gh`
subprocess invocation, not accepted from `gbrain bootstrap`'s own CLI args.

Confirmed via `git fetch upstream && git log HEAD..upstream/master --oneline`
(0 commits) that this is not upstream drift — purely caused by this PR's own
diff.
2026-08-13 05:12:01 -07:00
Ziyang Guo 758a2d4293 fix(takes): emit JSON for page extraction (#4004)
Fixes #3962

Return the structured extraction result for --json callers while preserving the existing human summary. Add a behavior-level regression test that proves stdout is parseable JSON.
2026-08-13 05:01:29 -07:00
MasaandClaude Opus 5 52389dbe5b fix(conversation-parser): add markdown-heading turn pattern (## User / ## Assistant) (#4005)
* fix(facts): make transcript pages facts-extraction eligible

`gbrain extract-conversation-facts`'s ALLOWED_TYPES allowlist omitted the
`transcript` page type, so gbrain's own nightly transcript-ingest pages
were silently skipped by both the CLI `--types` validation and the
`cycle.conversation_facts_backfill.types` config filter. Even with the
type allowed, the built-in conversation-parser had no pattern for the
`## User` / `## Assistant` markdown-heading turn shape that transcript
ingest writes into `compiled_truth`, so parsing would still yield 0
segments.

This PR makes an explicit decision: transcript pages ARE now
facts-extraction eligible. That is a real behavioral change (a new,
potentially large corpus starts flowing through the extraction +
segment-cost path), not a no-op bugfix — flagging it plainly rather than
padding out the change as narrower than it is.

Changes:
- `src/commands/extract-conversation-facts.ts`: add `'transcript'` to
  `ALLOWED_TYPES` / `ALLOWED_TYPE_ALIASES` (the single source of truth
  for this allowlist).
- `src/core/conversation-parser/builtins.ts`: add the `markdown-heading-turn`
  builtin pattern recognizing heading-only `## User` / `## Assistant` /
  `## Human` / `## System` lines as turn openers, with D5 continuation-line
  body absorption. `quick_reject` is deliberately scoped to the role-prefix
  (not a bare `#{2,3}` heading check) so a message body that happens to
  paste unrelated markdown headings doesn't starve the D18 scorer's
  anchor-candidate ratio.
- `src/commands/jobs.ts`, `src/commands/doctor.ts` (x2 checks),
  `src/commands/sources.ts`: these each carried their own hand-copied
  literal of the same allowed-types list (background-job type filter,
  `conversation_facts_backlog` doctor check, `conversation_format_coverage`
  doctor check, `facts_backfill_estimate`). Switched each to import
  `ALLOWED_TYPES` from the command module instead of re-listing it, so this
  class of drift (a type added in one place, silently excluded everywhere
  else) can't recur.
- `docs/architecture/KEY_FILES.md`: updated the two stale mentions (pattern
  count 17→18, allowlist list) to current-state per this repo's own
  reference-doc convention.

Known limitation (not fixed here, scope-bounded intentionally): parsing is
context-free, same as every other multi-line builtin in this registry — a
message body that contains a literal `## User` line (e.g. someone pasting
a markdown transcript excerpt into their own message) would be read as a
turn boundary. This is a pre-existing property of the whole parser
(`applyPattern`'s per-line scan has no fence-awareness), not something
this PR introduces or could fix without a much larger, separate change to
the shared orchestrator affecting all 18 patterns. Flagging it here rather
than silently shipping the same limitation as the other 17 builtins.

Tests: 4 new tests (2 in test/extract-conversation-facts.test.ts, 2 in
test/conversation-parser/parse.test.ts) covering the allowlist, the new
pattern's positive match + continuation absorption, and that ordinary
`## Summary`-style headings are correctly rejected. Full targeted suite
(conversation-parser + facts-extraction + doctor backlog + build-llms
freshness): 263 pass / 0 fail. typecheck clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0126D3zLWL5RE3CVxnPANiiU

* fix(facts): read the type allowlist from core, not the command module

CI caught this: the known-flags registry drifted for doctor, sources, and
repos. The obvious remedy the guard prints -- regenerate and commit -- would
have been a regression, so this takes the other route.

The generator walks one level of a command module's relative imports and
harvests every flag-shaped string it finds, help text included, and is
deliberately over-inclusive. Importing extract-conversation-facts.ts just to
read ALLOWED_TYPES therefore spliced that command's entire flag vocabulary
(--types, --sleep, --slug, --segment-limit, --override-disabled, ...) into
the allowlists of three commands that implement none of it: `gbrain doctor
--types foo` would have passed validation and been silently ignored. That is
the exact defect class #2185 exists to close.

(jobs.ts is unaffected: it already imported the command module on one line
for runExtractConversationFactsCore, so those flags were already in its
registry entry before this branch.)

ALLOWED_TYPES + ALLOWED_TYPE_ALIASES now live in
src/core/conversation-facts-types.ts, a constants-only module with no CLI
text to harvest. extract-conversation-facts.ts re-exports both so its
existing importers are unchanged.

Verified: registry regenerates to zero drift (was doctor/repos/sources),
cli-flag-validation 24 pass, typecheck clean, 287 pass across the touched
areas. Confirmed against a clean upstream/master worktree that the drift was
introduced by this branch and is not pre-existing.

* fix(conversation-parser): reduce to the parser pattern only

Withdraws the `transcript` allowlist half of this branch. The premise was
wrong: `transcript` is not an upstream page type. `ALL_PAGE_TYPES` does not
contain it, `gbrain-base.yaml` declares `conversation` for "long-running
chat/transcript pages" and marks it `extractable: true` precisely so
extract-conversation-facts walks it, and `gbrain-base-v2.yaml` lists
`transcript` as an alias of `source` (a media primitive). Pages typed
`transcript` are a convention of my own ingest pipeline, not something
upstream produces — the fix for that belongs on my side, by emitting
`conversation`.

That takes the four call-site de-duplications with it (they existed only to
keep the allowlist in sync), and with them the flag-registry drift: no
imports are added, so the registry regenerates to zero drift with no
constants module needed.

What remains is the half that stands on its own: a `conversation` page whose
body uses `## User` / `## Assistant` headings matches none of the 17 builtins
and parses to 0 segments. `markdown-heading-turn` is an 18th pattern in the
same shape as the iMessage/Circleback additions before it.

Verified: typecheck clean, 181 pass / 0 fail across the parser, extraction,
flag-registry and llms-freshness suites, registry drift zero.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 04:51:32 -07:00
Sean GearinandClaude Fable 5 fb141969f5 fix(skills): company-brainify — close three sanitization-defeating defects (#4048)
* fix(skills): company-brainify — close three sanitization-defeating defects

1. Scope truncation: the first structural grep used '>' and overwrote the
   retrieval-discovered scope list; Phase 1 now writes retrieval paths to
   /tmp/brainify-scope.txt explicitly and both greps append.
2. Facts reconciliation: the skill claimed 'gbrain sync' makes the DB stop
   serving deleted Facts-fence rows. Sync's convergence contract covers page
   import only — fact extraction is explicitly decoupled (src/commands/sync.ts
   CONVERGENCE CONTRACT); the reconcile lives in the extract-facts sweep
   (src/core/cycle/extract-facts.ts). The procedure now triggers the sweep and
   verifies removal with 'gbrain recall --grep' before certifying.
3. Backup retention glob: the backup is created as
   shared-brain-history-backup-<ts>.git but cleanup documented
   brain-history-backup-<date>.git — a pattern that matches nothing, silently
   retaining the pre-sanitization history mirror forever. Globs now agree.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(skills): regenerate skills.lock.json after master fix-wave

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 04:37:15 -07:00
Sean GearinandClaude Fable 5 fa5ec8399f fix(skills): citation-graph-ingest — check-backlinks requires a subcommand (#4049)
* fix(skills): citation-graph-ingest — check-backlinks requires a subcommand

Bare 'gbrain check-backlinks' exits with a usage error; the CLI requires
'check' or 'fix' (src/commands/backlinks.ts runBacklinks). The hygiene step
now invokes 'check-backlinks check', matching every other invocation in the
skill pack.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(skills): regenerate skills.lock.json after master fix-wave

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 04:27:44 -07:00
paul-0320andClaude Fable 5 3ce296e315 test(sweep): reset console-prefix's stdout redirect in the shared afterEach — the ENG-5 serve harness leaks it across files (#4061)
runServe()'s stdio path flips console-prefix's module-global
stdout→stderr redirect (#3844). bun test runs every file in one
process, so after sweep.test.ts's serve-wiring tests the flag stays
on and any later file pinning slog's stdout routing fails
(test/sync-all-parallel.test.ts, test/console-prefix.test.ts) —
shard-composition dependent, so it surfaces as a flake. Same reset
the donor harness (test/serve-stdio-lifecycle.test.ts) already
carries.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 04:13:40 -07:00
Garry TanandClaude Fable 5 1ec6a6e842 v0.45.9.0 feat(bootstrap): first-class cloud-sandbox install + per-turn persistence + fail-closed privacy ladder (#4045)
* feat(core): execution-environment detection — local | cloud-sandbox | ephemeral-container

detectExecutionEnvironment() + isCredentialInjectingProxy() with injected
signals (CLAUDE_CODE_REMOTE, cse_ session-id prefix, proxy-injected token
placeholder, anthropic-egress proxy JWT, container markers). binaryOnPath
moves here as the canonical PATH probe. autopilot's detectInstallTarget
ephemeral branch now routes through the shared detector.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(bootstrap): repo-visibility ladder + per-turn Stop push + same-session failure banner

The durability lane. One repo-visibility verdict for every consumer (REST
first — never GraphQL, which sandbox proxies pin; authed ls-remote + an
attributed anonymous probe as the git-protocol fallback), replacing three
drifted probes. A 200 counts as public only with advertisement proof; a
401/404 counts as private-signal only with an auth challenge — fail-closed
in BOTH directions. Private verdicts cache 1h (private-only, per origin).

gbrain hook stop now spawns a debounced detached push per turn (per-root
state; cloud-sandbox defaults to every turn, elsewhere 5 min; a failing
status bypasses the debounce), closing the /exit and VM-reclaim gaps.
Push status is per workspace root, read through one shared reader by the
user-prompt banner (additionalContext + systemMessage — visible to the
human, not just the model), the SessionStart note, and doctor. Escape
hatches for self-hosted git: flag > env > file-plane config key.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(bootstrap): environment-aware install — honest cron skip, cloud repo-create guard, execution_env surfaces

installDurabilityCron probes for crontab before writing anything (containers
and cloud sandboxes ship without one — expected, reported as an honest skip
naming the event-driven pushes that still cover persistence). The repo phase
installs the container-friendly harden half (post-commit hook, no scheduler)
outside local machines. createPrivateRepo fails fast in cloud sandboxes with
the flow that works (create outside, open the session ON the repo, attach).
bootstrap verify gains a never-gating execution_env check; bootstrap status
--json carries execution_environment for installing agents to branch on.

Also fixes a live-PATH resolution class: binaryOnPath and the crontab execs
now pass the current env explicitly (Bun resolves against the startup
snapshot otherwise).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(bootstrap): lifecycle hygiene — uninstall teardown, doctor job liveness, .mcp.json out of the repo, honest persistence copy

uninstall now tears down the durability wiring it installed (launchd/cron
job, untracked post-commit hook, credential wiring — the committed helper
and AGENTS rules stay). doctor gains bootstrap_durability_job: presence +
LIVENESS (launchctl load-state, crontab line, pull-log freshness) — a plist
on disk with a dead job no longer reads as healthy. Rendered .gitignore now
covers .mcp.json (absolute machine paths must not land in the private
repo); verify warns on pre-fix installs that committed it; the never-built
state/mcp.json promise is gone from GITHUB.md. Persistence copy tells the
truth everywhere: event-driven pushes do the durability work, the 30-min
job is a multi-machine pull freshener. Post-commit hook install/removal is
worktree-safe (git-path resolution; the git marker is a FILE there) and the
cron wrapper's self-disable tests the repo dir, not its git marker. Two
follow-up TODOs filed (plugin hook distribution; Channels push lane).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(bootstrap): committed hook carrier for cloud + cloud-setup-script emitter + cloud runbook

Cloud sessions clone fresh and snapshot hook config at session start — the
gitignored settings.local.json never exists there, so hooks never fired in
cloud at all. Cloud installs now write the repo-COMMITTED .claude/settings.json
with PATH-resolved, fail-open commands (no machine paths; a host without the
binary no-ops); local installs keep settings.local.json; the writers enforce
that one event never fires from both carriers, and removal cleans both.

New: gbrain bootstrap cloud-setup-script prints the paste-ready environment
setup script (npm transport — bun fetching is proxy-incompatible in cloud;
never the unrelated npm-registry package). Runbook gains a NEVER FABRICATE
TOOLING hard rule, a cloud-sandbox section (expected degradations as facts to
relay, the attach-first flow), and failure-table rows for the proxy-403 and
missing-crontab signatures. Codex lane stated honestly: AGENTS.md Gate 2 now
has the pull-side push-health check. Guide documents the new knobs and the
cloud contract; llms bundles regenerated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(bootstrap): per-turn push e2e chain, cloud-sandbox sim, per-root status reader migration + registry regen

bootstrap-persistence e2e gains the Stop-hook chain (one stop banks the turn
to the real bare remote; the debounce holds across stops; debounce-0 lands
consecutive turns) and fixes a pre-existing post-#4024 break: repoPhaseComplete
required a github-parseable origin, so session-end pushes deferred FOREVER for
self-hosted/file-transport origins — non-github repo_urls now bind by exact
URL equality (redirect protection preserved). Degraded-modes e2e gains the
cloud-sandbox simulation (status reports the environment; repo creation
refuses with the attach flow). workspace-push tests read per-root status
through the shared reader. Flag registry regenerated (new git argv literals
from the git-path/ls-files calls — the accepted argv-bleed class).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(doctor): categorize bootstrap_durability_job (categories drift guard)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(bootstrap): adversarial-review hardening — fail-closed privacy, no push-storm, no exfil paths

Both Claude and Codex adversarial passes ran; 11 findings fixed (union):
- Non-github 401+WWW-Authenticate no longer reads as private (RFC 7235 makes
  the header mandatory on every 401, so a middlebox 401s identically) — the
  top exfil path both models flagged; now unverifiable/fail-closed, operator
  confirms via the escape hatch. github.com still needs x-github-request-id.
- Escape hatches downgrade ONLY 'unverifiable' — a PROVEN-public origin still
  refuses (hatches never authorize a public push).
- treeNeedsPush measures against origin/<branch> (the push's own ref), not
  @{u}: a no-upstream branch no longer reports a committed-but-unpushed tree
  as push_clean and silently strands it.
- [D20] failing-retry uses a fixed 60s floor, not min(debounce,60s) — cloud
  debounce=0 no longer re-runs the network ladder every turn.
- Committed hook carrier: dedupe/suppress only on the EXACT portable-command
  shape, not a 'gbrain hook' substring (blocks the suppress-local-run-evil
  supply-chain vector); GBRAIN_HOME refused in the committed carrier.
- push-status reason sanitized (charset+length) at every surface (banner,
  doctor, status blob) so remote git stderr can't inject via the remediation.
- Per-root state: ghost-root records (deleted workspaces) filtered so a dead
  failing record can't re-fire the banner forever; uninstall removes them.
- statusReport support blob reads push status through the shared per-root
  reader; visibility cache strips URL userinfo (no PAT persisted).
- anonProbe strips userinfo + redirect:manual + SSRF flags + --end-of-options
  on ls-remote; cron self-disable uses git rev-parse (worktree-safe both
  ways); cloud-setup-script fails loud on a broken update; durability liveness
  won't certify a never-run crontab as live; config get/unset resolve the
  dotted file-plane keys; .bak/.broken gitignored; typed config fields.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* v0.45.8.0 feat(bootstrap): first-class cloud-sandbox install + per-turn persistence + fail-closed privacy ladder

VERSION + package.json + CHANGELOG + TODOS + llms bundles.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: sync KEY_FILES + README for the cloud-DX wave (v0.45.8.0)

New KEY_FILES entries for execution-env.ts and repo-visibility.ts; updated the
bootstrap repo/hooks, workspace-push, brain-repo-durability, and hook.ts
entries to current behavior (ladder verification, committed cloud hook carrier,
per-root push status, crontab probe + liveness, per-turn stop push + banner).
README's Claude Code line now states per-turn + cloud persistence honestly.
llms bundles regenerated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(bootstrap): doc-review pass — attach records repo_url (enables cloud persistence), verify reads per-root push status, honest docs

Cross-model doc review (Codex) caught that the headline cloud-persistence
claim was hollow and two docs over-claimed:
- attach now records repo_url from the adopted origin, so the no-daemon push
  gate (repoPhaseComplete) recognizes the repo phase as done — WITHOUT this,
  the per-turn/session-end pushes deferred forever after an attach, which is
  the ONLY install path in a cloud sandbox (repo is refused there). Privacy is
  still enforced at push time by the ladder.
- bootstrap verify's push_probe reads the shared per-root reader [D8], not the
  legacy single file — a fresh v0.45.8 install no longer reports 'no push
  recorded' when per-root status exists.
- Docs corrected to match code: runbook stamp → 0.45.8.0; README states the
  per-turn cadence honestly (debounced local, next-turn failure notice);
  GITHUB.md qualifies auto-push as Claude Code (Codex is pull); KEY_FILES says
  repo/status use REST (not the full ladder) and drops the stale --push;
  bootstrap.md hooks-location covers both carriers; README verbs surface says
  seven. llms bundles regenerated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(bootstrap): regenerate vendored template-repo tree + isolate env-mutating tests (verify gate)

The verify CI gate caught two things the piecemeal local runs missed:
- templates/bootstrap/template-repo/ is a GENERATED tree; the source-template
  edits (AGENTS.md Gate 2, CLAUDE.md cloud note) plus the version stamp had
  drifted. Regenerated from source (check:bootstrap-templates green).
- test/bootstrap-repo.test.ts + test/durability-cron.test.ts newly mutated
  process.env in non-serial files; converted to withEnv() (check:test-isolation
  green) — no .serial rename needed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(ambient-recall): pin a keyless gateway so delta/context_pack writes never fire a real embed

Root cause of the shard-2 CI red after the 0.45.9.0 re-bump: adding this
wave's test files reshuffled the weight-packed shards, moving
ambient-recall.test.ts next to a neighbor that leaks CI's dummy
OPENAI_API_KEY (sk-test-*) into the gateway singleton (the bunfig preload
configures with env:{...process.env}, and a present-but-invalid key turns
remember's keyless-degrade embed into a hard 401). The delta/context_pack
tests exercise cursor + budget logic, not embedding quality, so this pins a
keyless gateway (env:{}) in beforeAll — isAvailable('embedding') is false,
writeSingleFact degrades (degraded_dedup) with no HTTP call, and the file is
deterministic regardless of shard bin-packing. Verified: passes with
OPENAI_API_KEY=sk-test-* set (the CI condition). Master's own comment in
legacy-embedding-preload.ts already warns 'adding any test file reshuffles
the mines'; this makes the file immune.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 16:12:59 -07:00
068f586128 docs(release): document the bootstrap runbook + template-tree version stamps in the version-locations table (#4051)
Both are CI-enforced on every VERSION bump (check-bootstrap-tag.sh,
check-bootstrap-templates.sh) but were missing from the table that
release flows read first.

Co-authored-by: test <test@test.co>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 14:58:27 -07:00
testandClaude Fable 5 4dc77c3979 v0.45.8.0 fix: community fix-wave — 25 PRs from 24 contributors (MCP correctness, sync/import data safety, doctor accuracy)
Every fix reviewed, tested in isolation, adversarially verified, security
reviewed, and re-tested as one composed branch. Full attribution preserved
via per-commit Co-Authored-By trailers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 14:38:36 -07:00
test 3e4bc112d8 test: pin file_list BigInt normalization against the ctx-engine seam (#3869 rider)
file_list now routes through sqlQueryForEngine(ctx.engine); the pin keeps
the identical BigInt-normalization invariant but mocks the new seam
instead of the retired module-global db connection.
2026-08-12 14:38:36 -07:00
test a4422f96ef test: genericize fixture slugs per the repo privacy rule (#3869 rider)
check:test-names guard flags private agent-fork names in test fixtures;
concepts/example-board carries the same path shape.
2026-08-12 14:38:36 -07:00
test 447f81956d test(e2e): complete the embedding.ts mock export surface for the cycle-sync import path
The wave's sync changes make the cycle sync phase lazily import
commands/sync.ts, whose static embedding.ts imports must all resolve
against the test's module mock (a missing name is a load-time
SyntaxError). Stubs mirror the real pure functions.
2026-08-12 14:38:36 -07:00
Garry Tan dcad42534e chore(wave): regenerate CLI flag registry for wave flag additions
Freshness guard (#2185) requires the committed registry to match a fresh
generator run after the wave's flag changes.
2026-08-12 14:38:36 -07:00
Garry TanandSergey Sholom ce156eb8ed fix(sync): unquote git C-style-quoted paths in buildSyncManifest (#3897) (#3899)
Wave-assembled from PR #3899 by @SergeyShol.

Co-Authored-By: Sergey Sholom <sergey.sholom@gmail.com>
2026-08-12 14:38:36 -07:00
Garry Tanandjavieraldape 3f595083fe fix(mcp): honor dotfile source for stdio serve (#3906) (#3965)
Wave-assembled from PR #3965 by @javieraldape.

Co-Authored-By: javieraldape <6430563+javieraldape@users.noreply.github.com>
2026-08-12 14:38:36 -07:00
Garry TanandHunter Veltri d8e3772810 fix(autopilot): resolve gbrain CLI on Windows via %PATH% enumeration (#3832)
Wave-assembled from PR #3832 by @veltri-23.

Co-Authored-By: Hunter Veltri <veltrifinancial@gmail.com>
2026-08-12 14:38:36 -07:00
Garry TanandBen Sheridan Edwards 810d1c5540 fix(serve): route all stdout logging to stderr under stdio MCP (#3844)
Wave-assembled from PR #3844 by @BenSheridanEdwards.

Co-Authored-By: Ben Sheridan Edwards <bensheridanedwards@gmail.com>
2026-08-12 14:38:36 -07:00
Garry TanandBo 2dc33fb865 fix(import): call clearFailures() for paths that succeed this run (#3843)
Wave-assembled from PR #3843 by @bo-developing.

Co-Authored-By: Bo <bo.developing@gmail.com>
2026-08-12 14:38:36 -07:00
Garry TanandKyle Cooper dc6e61b07f fix(pglite): batch code edge inserts below bind limit (#4010)
Wave-assembled from PR #4010 by @kyle944.

Co-Authored-By: Kyle Cooper <kyle@erudireworkforce.com>
2026-08-12 14:38:36 -07:00
Garry TanandPhilip Eriksson 30c81b709c fix(eval): resolve BrainBench defaults from package root (#3996)
Wave-assembled from PR #3996 by @philip-rossoneri.

Co-Authored-By: Philip Eriksson <philip@rossoneri.se>
2026-08-12 14:38:36 -07:00
Garry TanandNidhin Tamil 0a1890bbf8 fix(sync): never write a baseline commit over an already-populated repo (#3964)
Wave-assembled from PR #3964 by @NidTamil.

Co-Authored-By: Nidhin Tamil <nidhin@ymail.com>
2026-08-12 14:38:36 -07:00
Garry Tanandmasashiono0611 a849d833eb fix(frontmatter): drop the shipped rule binding a personal folder to a sensitive category (#3949)
Wave-assembled from PR #3949 by @Masashi-Ono0611.

Co-Authored-By: masashiono0611 <masashi.ono.0611@gmail.com>
2026-08-12 14:38:36 -07:00
Garry TanandSofía González 23c7b0eb16 fix(import): reject malformed YAML frontmatter (#3708) (#3923)
Wave-assembled from PR #3923 by @javieraldape.

Co-Authored-By: Sofía González <sofiagonzalez@Sofias-MacBook-Air.local>
2026-08-12 14:38:36 -07:00
Garry Tanandmasashiono0611 94ec7e31e0 fix(minions): verify the finally-resolved model at the subagent gate — the models.subagent config path bypassed capability checks (#3919)
Wave-assembled from PR #3919 by @Masashi-Ono0611.

Co-Authored-By: masashiono0611 <masashi.ono.0611@gmail.com>
2026-08-12 14:38:36 -07:00
Garry Tanandmasashiono0611 3fa0a5acb5 fix(dream): do not permanently cache truncated or degenerate significance verdicts (#3918)
Wave-assembled from PR #3918 by @Masashi-Ono0611.

Co-Authored-By: masashiono0611 <masashi.ono.0611@gmail.com>
2026-08-12 14:38:36 -07:00
Garry TanandMike Williamson 8ecd52022e fix(ai): match hyphenated Qwen3-Embedding ids for dimensions passthrough (#3909)
Wave-assembled from PR #3909 by @mikez93.

Co-Authored-By: Mike Williamson <mike@imekka.com>
2026-08-12 14:38:36 -07:00
Garry Tanandmasashiono0611 e795324ec5 fix(doctor): surface abandoned PGLite stores after an engine migration (#3856) (#3879)
Wave-assembled from PR #3879 by @Masashi-Ono0611.

Co-Authored-By: masashiono0611 <masashi.ono.0611@gmail.com>
2026-08-12 14:38:36 -07:00
Garry TanandDavid Paluy f7d63c7159 fix: MCP file ops use the connected OperationContext engine, not the global DB singleton (#3869)
Wave-assembled from PR #3869 by @dpaluy.

Co-Authored-By: David Paluy <dpaluy@users.noreply.github.com>
2026-08-12 14:38:36 -07:00
Garry TanandSean Gearin 0a34ced5d7 fix(mcp): treat null / empty-string optional params as absent at dispatch (#3850)
Wave-assembled from PR #3850 by @SeanGearin.

Co-Authored-By: Sean Gearin <sean@indistinct.ai>
2026-08-12 14:38:36 -07:00
Garry TanandSofía González 2dbaebbe16 fix(progress): preserve Ctrl-C termination for shared reporter (#3614) (#3825)
Wave-assembled from PR #3825 by @javieraldape.

Co-Authored-By: Sofía González <sofiagonzalez@Sofias-MacBook-Air.local>
2026-08-12 14:38:36 -07:00
Garry TanandBrett b0b9af042f fix(models): replace the /v1 doctor hint with a /models-probe base-URL classifier (#3787)
Wave-assembled from PR #3787 by @brettdavies.

Co-Authored-By: Brett <brettdavies@users.noreply.github.com>
2026-08-12 14:38:36 -07:00
Garry TanandConstantin Gozman 8db07e4a79 fix(takes): resolve the page before writing markdown in takes add (#3781)
Wave-assembled from PR #3781 by @ghizi.

Co-Authored-By: Constantin Gozman <constantingozman@gmail.com>
2026-08-12 14:38:36 -07:00
Garry TanandYMYD f7d4f19124 fix(schema): apply the configured FTS language to the schema template — initSchema replay reverts non-English brains to 'english' (#3774)
Wave-assembled from PR #3774 by @paul-0320.

Co-Authored-By: YMYD <paul@ymyd.co.kr>
2026-08-12 14:38:36 -07:00
Garry Tanandarisgysel-design b966d2682c test(openclaw): inspect runtime in plugin loader E2E (#3742)
Wave-assembled from PR #3742 by @arisgysel-design.

Co-Authored-By: arisgysel-design <arisgysel-design@users.noreply.github.com>
2026-08-12 14:38:36 -07:00
Garry Tanandsmdesai27 636628fdb2 fix(sync,import): global sync.* anchors only move for the brain repo they describe (#2114) (#3735)
Wave-assembled from PR #3735 by @smdesai27.

Co-Authored-By: smdesai27 <sanilmdesai@gmail.com>
2026-08-12 14:38:36 -07:00
Garry TanandPaolo Belcastro cb07cfda8d fix(budgeting): add Sonnet 5, Fable 5, and Opus 4.8 to the synthesize context map and brainstorm output caps (#3727)
Wave-assembled from PR #3727 by @p3ob7o.

Co-Authored-By: Paolo Belcastro <p3ob7o@users.noreply.github.com>
2026-08-12 14:38:36 -07:00
Garry TanandGreg Jackson 9b6db85d39 fix(skills): advertise usable tools on stdio transport (#3725)
Wave-assembled from PR #3725 by @gregario.

Co-Authored-By: Greg Jackson <gregj64@gmail.com>
2026-08-12 14:38:36 -07:00
Garry TanandGreg Jackson 8d5bdfe125 fix(mcp): reject sources_add path on remote transport (#3724)
Wave-assembled from PR #3724 by @gregario.

Co-Authored-By: Greg Jackson <gregj64@gmail.com>
2026-08-12 14:38:36 -07:00
373 changed files with 38386 additions and 2209 deletions
+203 -13
View File
@@ -1,7 +1,7 @@
name: Heavy Tests
# Heavy ops-shape tests under tests/heavy/. Cost minutes per run; NOT part
# of default PR CI. Two triggers:
# of default PR CI. Three triggers:
# - Nightly schedule (catches regressions within 24h of merge to master).
# - On-demand opt-in via PR label `heavy-tests` (slow loop kept off by default).
# - Manual workflow_dispatch for triage.
@@ -102,14 +102,15 @@ jobs:
retention-days: 14
if-no-files-found: ignore
# Real-agent door e2e: drives the ACTUAL `claude` + `codex` binaries (no PATH
# shims) against a real gbrain over MCP. These pay real API cost and need the
# binaries installed + authed, which a stock GitHub runner does NOT have — so
# both tests self-SKIP (describe.skipIf on binary/auth) and the job is a clean
# no-op here. It exists so a self-hosted / manually-provisioned runner WITH
# authed claude/codex (and ANTHROPIC/OPENAI creds) actually exercises the real
# binaries. Heavy cadence only (nightly + `real-agent-e2e` label + dispatch);
# NEVER the PR shard matrix.
# Real-agent door e2e: drives the ACTUAL `claude` + `codex` + `hermes`
# binaries (no PATH shims) against a real gbrain over MCP. These pay real API
# cost and need the binaries installed + authed, which a stock GitHub runner
# does NOT have — so the tests self-SKIP (describe.skipIf on binary/auth) and
# the job is a clean no-op here. It exists so a self-hosted /
# manually-provisioned runner WITH authed claude/codex/hermes (and
# ANTHROPIC/OPENAI creds) actually exercises the real binaries. Heavy cadence
# only (nightly + `real-agent-e2e` label + dispatch); NEVER the PR shard
# matrix.
real-agent-e2e:
name: Real-agent door e2e (skips without authed binaries)
if: |
@@ -118,6 +119,10 @@ jobs:
contains(github.event.pull_request.labels.*.name, 'heavy-tests')
runs-on: ubuntu-latest
timeout-minutes: 20
env:
# Open the hermes opt-in door here so binary/auth absence — not the
# opt-in var — is what skips (same posture as the claude/codex doors).
GBRAIN_REAL_HERMES_E2E: '1'
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
@@ -125,15 +130,16 @@ jobs:
bun-version: 1.3.13
- run: bun install
# Reference both door tests; run only the ones present (the claude door
# may land in a sibling PR). Missing binary/auth → the file self-skips, so
# a stock runner reports a green no-op rather than failing.
# Reference the door tests; run only the ones present (a door may land
# in a sibling PR). Missing binary/auth → the file self-skips, so a
# stock runner reports a green no-op rather than failing.
- name: Run real-agent door tests
run: |
files=()
for f in \
test/e2e/bootstrap-real-claude.serial.test.ts \
test/e2e/bootstrap-real-codex.serial.test.ts; do
test/e2e/bootstrap-real-codex.serial.test.ts \
test/e2e/install-real-hermes.serial.test.ts; do
[ -f "$f" ] && files+=("$f")
done
if [ "${#files[@]}" -eq 0 ]; then
@@ -144,3 +150,187 @@ jobs:
# --timeout: real-agent turns are slow (live claude/codex); the door
# tests self-skip without authed binaries so this is a no-op elsewhere.
bun test --timeout=600000 "${files[@]}"
# Hermes door e2e: unlike real-agent-e2e above (best-effort, self-skipping),
# this job PROVISIONS the real hermes binary itself — pinned installer digest,
# non-interactive auth + model pin — and then requires the door tests to
# actually execute. It pays real API cost, so it stays label-gated at heavy
# cadence (nightly + `real-agent-e2e`/`heavy-tests` label + dispatch); NEVER
# the PR shard matrix. Loud-fail throughout: a runner that cannot install or
# auth hermes fails this job rather than skipping.
hermes-door:
name: Hermes door e2e (real binary, loud-fail)
if: |
github.event_name != 'pull_request' ||
contains(github.event.pull_request.labels.*.name, 'real-agent-e2e') ||
contains(github.event.pull_request.labels.*.name, 'heavy-tests')
runs-on: ubuntu-latest
# Four serial door tests at 600s each plus the installer budget cannot
# fit the sibling job's 20 minutes.
timeout-minutes: 40
env:
# Pin values documented in docs/mcp/HERMES-CLI-PIN.md — update them
# together, deliberately, after reviewing upstream changes. The digest
# pins the INSTALLER SCRIPT; the tag + commit pin the PAYLOAD it clones
# (without them, the installer pulls upstream main into the runner that
# later holds secrets). The commit is v2026.8.3's dereferenced SHA —
# immutable even if the tag moves.
HERMES_VERSION: "0.20.0"
HERMES_GIT_TAG: "v2026.8.3"
HERMES_GIT_COMMIT: "3c27eb6234bf91b8ceee9e9071591b31e9b148cb"
HERMES_INSTALL_SHA256: "c118ff31618dc70339049ce71061b8f1351a1c70d9c2a236ed50d8a2550c550d"
GBRAIN_REAL_HERMES_E2E: '1'
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: 1.3.13
- run: bun install
# `runner.temp` is not an allowed context in job-level env, so the
# evidence dir is derived here and exported for every later step (the
# door test writes into it; the failure-path upload reads it — exporting
# early keeps that upload working even when an install/precondition step
# is what failed).
- name: Prepare evidence dir
run: |
echo "GBRAIN_E2E_EVIDENCE_DIR=$RUNNER_TEMP/hermes-door-evidence" >> "$GITHUB_ENV"
mkdir -p "$RUNNER_TEMP/hermes-door-evidence"
# NO secrets in this step's env: the installer is downloaded from the
# network and executed, so it must never see credentials. The digest pin
# turns an upstream installer change into a loud failure instead of
# unreviewed code running in CI. Bound + retry the install run itself
# (same posture as the e2e tier2 OpenClaw install): `timeout` kills a
# hung attempt, 3 attempts ride out transient flakes, step cap backstops.
- name: Install hermes (pinned installer digest)
timeout-minutes: 15
run: |
curl -fsSL --retry 3 -o hermes-install.sh https://hermes-agent.nousresearch.com/install.sh
if ! echo "$HERMES_INSTALL_SHA256 hermes-install.sh" | sha256sum -c -; then
echo "::error::hermes installer digest drift — re-pin deliberately: update HERMES_INSTALL_SHA256 + HERMES_VERSION in this workflow and docs/mcp/HERMES-CLI-PIN.md after reviewing upstream changes" >&2
exit 1
fi
for attempt in 1 2 3; do
if timeout 600 bash hermes-install.sh --skip-setup --non-interactive --branch "$HERMES_GIT_TAG" --commit "$HERMES_GIT_COMMIT"; then
# The branch/commit flags above are ASSERTED here, not trusted:
# a shell installer that silently ignores unknown flags would
# clone upstream main into a runner that later holds secrets.
# Verify the actual checkout before anything else runs it.
actual_commit=$(git -C "$HOME/.hermes/hermes-agent" rev-parse HEAD 2>/dev/null || echo "no-git-checkout")
if [ "$actual_commit" != "$HERMES_GIT_COMMIT" ]; then
echo "::error::hermes payload drift — installed checkout is $actual_commit, pinned $HERMES_GIT_COMMIT. Either the installer ignored its branch/commit flags or the layout moved from ~/.hermes/hermes-agent; re-pin deliberately (HERMES_GIT_TAG/HERMES_GIT_COMMIT + docs/mcp/HERMES-CLI-PIN.md) after reviewing upstream." >&2
exit 1
fi
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
exit 0
fi
echo "::warning::hermes install attempt $attempt failed or timed out; retrying in 10s" >&2
sleep 10
done
echo "::error::hermes install failed after 3 attempts" >&2
exit 1
- name: Preconditions (binary, secret, version pin)
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
run: |
if ! command -v hermes >/dev/null 2>&1; then
echo "::error::hermes did not resolve on PATH after install" >&2
exit 1
fi
if [ -z "$ANTHROPIC_API_KEY" ]; then
echo "::error::ANTHROPIC_API_KEY secret is empty — fork PRs get no secrets from GitHub, and this labeled job cannot run without them" >&2
exit 1
fi
version_output=$(hermes --version)
echo "$version_output"
# Observed shape: `Hermes Agent v0.20.0 (2026.8.3)`.
if ! printf '%s' "$version_output" | grep -qF "v$HERMES_VERSION"; then
echo "::error::hermes version drift — expected v$HERMES_VERSION in: $version_output" >&2
exit 1
fi
- name: Configure hermes (auth + model pin)
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
run: |
mkdir -p ~/.hermes
printf 'ANTHROPIC_API_KEY=%s\n' "$ANTHROPIC_API_KEY" > ~/.hermes/.env
chmod 600 ~/.hermes/.env
# `hermes model` is interactive-only; `config set` is the observed
# non-interactive model pin.
hermes config set model.default anthropic/claude-haiku-4.5
# Global health check — informational only, never a gate here.
hermes doctor || true
- name: Run hermes door tests
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
run: |
# Redirect to a file, then tail — never pipe bun through tail (the
# pipe eats the real exit code and truncates failure details).
# `|| EXIT=$?` keeps the default `-e` shell from bailing before the
# tail runs.
EXIT=0
bun test --timeout=600000 test/e2e/install-real-hermes.serial.test.ts > door.txt 2>&1 || EXIT=$?
tail -40 door.txt
if [ "$EXIT" -ne 0 ]; then
# Preserve the FULL bun output for the failure artifact — bun
# prints failure details before the summary, so the 40-line tail
# above can drop exactly the lines a paid-CI triage needs.
cp door.txt "$GBRAIN_E2E_EVIDENCE_DIR/" 2>/dev/null || true
exit "$EXIT"
fi
# This job provisions the binary + auth above, so the door must
# actually EXECUTE: a summary with zero passing tests means the
# suite ran nothing or self-skipped everything — never let that
# read as green.
pass_count=$(grep -Eo '[0-9]+ pass' door.txt | tail -1 | grep -Eo '^[0-9]+' || true)
if [ -z "$pass_count" ] || [ "$pass_count" -eq 0 ]; then
echo "::error::hermes door summary shows no passing tests (nothing ran or everything skipped) — refusing to go green while testing nothing" >&2
exit 1
fi
# The door test copies its evidence into GBRAIN_E2E_EVIDENCE_DIR; the
# workflow only uploads it. The test already excludes credential files —
# the scrub below is defensive belt-and-suspenders before upload. Both
# steps also require the evidence-dir env (a failure before the prepare
# step leaves it unset, and there is nothing to upload then anyway).
- name: Scrub credentials from evidence (defensive)
if: failure() && env.GBRAIN_E2E_EVIDENCE_DIR != ''
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
run: |
# Three layers, because the evidence dir carries files WRITTEN BY
# THE THIRD-PARTY HERMES BINARY (logs/sessions) and upload-artifact
# follows symlinks:
# 1. filename scrub (env files),
# 2. symlink delete (an agent-dropped link could dereference to a
# real credential file at upload time),
# 3. content scrub (any file that embeds the key — auth-error dumps
# are most likely exactly on the failure path that uploads).
find "$GBRAIN_E2E_EVIDENCE_DIR" -type f \( -name '.env' -o -name '*.env' \) -exec rm -f {} + 2>/dev/null || true
find "$GBRAIN_E2E_EVIDENCE_DIR" -type l -delete 2>/dev/null || true
if [ -n "$ANTHROPIC_API_KEY" ]; then
grep -rlF "$ANTHROPIC_API_KEY" "$GBRAIN_E2E_EVIDENCE_DIR" 2>/dev/null | while IFS= read -r f; do
echo "::warning::removing evidence file containing the API key: ${f#"$GBRAIN_E2E_EVIDENCE_DIR"/}" >&2
rm -f "$f"
done
fi
- name: Upload hermes door evidence
if: failure() && env.GBRAIN_E2E_EVIDENCE_DIR != ''
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: hermes-door-evidence
path: ${{ env.GBRAIN_E2E_EVIDENCE_DIR }}
retention-days: 14
if-no-files-found: ignore
# Hosted ubuntu-latest runners are ephemeral, but this must not depend
# on that: if the job ever moves to a self-hosted runner (the sibling
# real-agent-e2e job is designed for one), a key left in ~/.hermes/.env
# would persist for every later workload on that runner.
- name: Remove hermes credentials (unconditional)
if: always()
run: rm -f ~/.hermes/.env
+93 -14
View File
@@ -1,4 +1,4 @@
<!-- gbrain-runbook-stamp: 0.45.7.0 -->
<!-- gbrain-runbook-stamp: 0.45.14.0 -->
<!-- This stamp must equal the VERSION file at every release; CI enforces it
(scripts/check-bootstrap-tag.sh). `gbrain bootstrap status` compares it to
the installed binary and warns on skew. -->
@@ -50,7 +50,17 @@ platform package manager first (`brew install gh`, `apt install gh`, `dnf instal
`winget install GitHub.cli` per the official instructions); never a piped
curl-to-shell one-liner. Install gbrain ONLY as
`bun install -g github:garrytan/gbrain#latest-stable` — the npm package named
"gbrain" is an unrelated project.
"gbrain" is an unrelated project. (Cloud-sandbox exception: bun's package fetching
is proxy-incompatible there — use the `gbrain bootstrap cloud-setup-script` recipe,
which installs from the same pinned GitHub source through npm.)
**NEVER FABRICATE TOOLING.** If gh or any preflight binary is missing, blocked
by a sandbox egress proxy, or answering 403s, report that through
`status`/`doctor` output and follow the cloud-sandbox guidance below. Never
hand-roll a gh shim, stub a fake binary into /usr/local/bin, or fake a passing
check — a fabricated tool poisons every later verification, and the one time it
was tried it masked a real silent-persistence failure. The CLI degrades honestly
on its own; your job is to relay, not to bridge.
## Codex preflight (ChatGPT desktop / Codex CLI only)
@@ -72,8 +82,11 @@ you needed; report the count at the end (it feeds the install-time measurement).
`gh auth login -h github.com -p https -w` (you run it; they click Authorize).
Then `gbrain bootstrap status` — it is idempotent and resume-aware; after any
partial failure, re-run it and continue where it points.
2. **Engine.** `gbrain init --pglite` (2 seconds, no server). Search mode defaults
to balanced silently — do NOT ask; the human can change it any time with
2. **Engine.** `gbrain init --pglite` (2 seconds, no server). Search mode is
auto-selected silently (conservative when keyless, tokenmax with an
expansion key) and printed with an `[AGENT]` cost matrix — surface that
matrix to the human and confirm before running high-volume queries (see
INSTALL_FOR_AGENTS.md Step 3.5); they can change it any time with
`gbrain search modes`. The one thing to raise here is the OPTIONAL provider
key — with no key you run keyless: keyword search plus memory you author
yourself through the write tools; everything works, one key upgrades search to
@@ -99,9 +112,15 @@ you needed; report the count at the end (it feeds the install-time measurement).
4. **Render.** `gbrain bootstrap render` — identity files appear. Show the human
SOUL.md. Existing files are never overwritten (re-runs are safe; `--force`
backs up first).
5. **Skills + brain wiring.** The CLI scaffolds the skill set and registers
`brain/` as the workspace source. Nothing to judge here; relay the output.
6. **Wire the harness.** `gbrain bootstrap hooks --harness <detected>`:
5. **Skills.** `gbrain skillpack scaffold --all` — the CLI scaffolds the skill
set. Nothing to judge here; relay the output.
6. **Wire the harness + register the brain source.** `gbrain bootstrap hooks
--harness <detected>` creates `<workspace>/brain` and prints the exact
`gbrain sources add <source_id> --path <brain> --force` command for THIS
workspace — run it verbatim (don't guess a different id; a guessed id
only surfaces as an FK error at `verify` time, by which point a wrong
guess also blocks the correct id with an `overlapping_path` error). It
also:
- Claude Code: installs per-turn hooks ON by default — do NOT ask; loading the
brain every turn is the whole point of installing gbrain for your agent. Tell
the human it is on and how to turn it off (`GBRAIN_HOOKS=0`, or re-run with
@@ -121,8 +140,9 @@ you needed; report the count at the end (it feeds the install-time measurement).
instead of creating one — verifies it is private and pushes the workspace. A
non-empty repo, or one owned by an org, is refused with a clear message (make an
empty personal repo, or run `gbrain bootstrap attach` for an existing agent
clone). Asks the background-persistence consent (15-minute scan-gated push job;
declining still persists at session end). If the human has no GitHub or declines:
clone). Asks the background-persistence consent (a git post-commit auto-push
plus a 30-minute pull job for multi-machine freshness; declining still persists
via the per-turn and session-end pushes). If the human has no GitHub or declines:
local-only mode with an honest warning; `bootstrap repo` can run any time later.
Note: the per-turn/session push stays deferred until this phase records the
verified repo, so nothing is ever pushed to an unverified-privacy origin.
@@ -130,7 +150,9 @@ you needed; report the count at the end (it feeds the install-time measurement).
through the real write path, graph floor, token sweep, secret scan, repo
privacy, hooks smoke, capability report (keyless or keyed). Exit 0 or it is not
done. Paste the report. Then relay the first-run tour it prints (three prompts
the human should try, starting with restarting the session).
the human should try, starting with restarting the session) AND the hand-off
block below it — the ownership line and the cold-start offer are the two
things the human must actually understand, not fine print.
## Machine two
@@ -139,6 +161,42 @@ initialized), run `gbrain bootstrap attach` instead of the interview/render/repo
phases — it wires this machine (source, hooks, MCP) and verifies. If agent.json
says it is an uninitialized template, proceed with the normal flow from phase 1.
## Cloud sandboxes (claude.ai/code and similar proxied environments)
**How you know:** `gbrain bootstrap status --json` reports
`execution_environment: "cloud-sandbox"` (the CLI detects the documented
signals — the CLAUDE_CODE_REMOTE env var, the proxy-injected token
placeholder). Trust the CLI's detection over your own guesses.
**Expected degradations — these are facts to relay, not bugs to bridge:**
- **No crontab, no surviving background processes.** The VM is reclaimed after
inactivity. The scheduled pull is skipped honestly; the per-turn (Stop hook)
and session-end pushes carry persistence. Decline nothing, fabricate nothing.
- **GitHub GraphQL is always blocked** by the egress proxy, and **REST reaches
only repos attached to the session** — a repo created mid-session is NOT
attached, so `gbrain bootstrap repo` refuses fast in cloud with the flow
that works. Privacy verification falls back to pure git protocol on its own.
- **`git push` works only against the session's working branch.** A user PAT
does not bypass any of this.
- **Only repo-committed files carry into the next session.** `~/.gbrain`,
`~/.claude`, and the gitignored `.claude/settings.local.json` evaporate.
Hooks therefore live in the COMMITTED `.claude/settings.json` (the CLI
writes PATH-resolved, fail-open commands there in cloud); hook config is
snapshotted at session start, so hooks written mid-session activate on the
NEXT session — say so instead of debugging it.
**The correct cloud flow:**
1. The human creates the private repo from a normal machine (or github.com)
and opens the cloud session ON that repo.
2. The environment's setup script installs the gbrain binary — print it with
`gbrain bootstrap cloud-setup-script` and have the human paste it into the
environment config (npm-based; bun's fetching is proxy-incompatible there).
3. Inside the session: `gbrain bootstrap attach`, then
`gbrain bootstrap hooks --harness claude-code` (writes the committed
carrier), commit + push, and tell the human the hooks go live next session.
## Failure modes, and what they actually mean
| Symptom | Real cause | Fix |
@@ -150,10 +208,31 @@ says it is an uninitialized template, proceed with the normal flow from phase 1.
| "bootstrap already running (pid N)" | A concurrent bootstrap holds the lock | Wait or investigate that pid; the lock self-clears when stale. |
| Brain tools fail with a lock error | Another live session's serve owns the database | Close the other session; sequential use is the v1 contract. |
| Hook reports "brain context unavailable" | serve not running or degraded | `gbrain doctor` names it; hooks fail open by design. |
| gh answers 403 "not enabled for this session" | Cloud proxy scoping — the repo is not attached to the session | Expected in cloud; the visibility ladder falls back to git protocol. NEVER shim gh. |
| "crontab: command not found" / cron skipped | Containers and cloud sandboxes ship without a scheduler | Expected; event-driven pushes cover it — the skip message says exactly this. |
| A turn shows "workspace push is FAILING" | The background push is refusing (visibility, secret-scan, or network reasons) | Run `gbrain doctor`; the banner repeats every 30 min until fixed. |
## Hand off
Finish by telling the human: the private repo URL (or the local-only status), the
capability mode (keyless vs keyed), the three commands they will actually reuse
(`gbrain doctor`, `gbrain bootstrap verify`, `gbrain sources push`), and the
first-run tour. Then delete nothing — this runbook was fetched, not installed.
Two things the human must UNDERSTAND before you finish — say them plainly, in
this order, and confirm they landed:
1. **They own the brain.** Every memory you keep is a markdown file in THEIR
private GitHub repo — name the URL. Owning it means: they can read it any
time, take it to a second machine (`gbrain bootstrap attach`), or delete the
repo and the brain is gone. If they went local-only, say that instead, with
`gbrain bootstrap repo` as the any-time upgrade.
2. **The first skill to run is cold-start.** An empty brain is a database; a
filled one is a memory — and every flagship skill (book-mirror, briefings,
meeting prep) only becomes magical once the brain holds their real life.
OFFER to run the cold-start skill now: it imports Gmail, calendar, and
contacts through ClawVisor (clawvisor.com — an OAuth vault; you never hold
raw tokens), or offline archives (Google Takeout, a notes folder) if they
prefer no third-party gateway. Every phase is consent-gated and
independently valuable — they can stop after any one. If they say "later",
that is a complete install; they can say "fill my brain" any time.
Then the routine facts: the capability mode (keyless vs keyed), and the three
commands they will actually reuse (`gbrain doctor`, `gbrain bootstrap verify`,
`gbrain sources push`). Then delete nothing — this runbook was fetched, not
installed.
+396 -7
View File
@@ -2,6 +2,401 @@
All notable changes to GBrain will be documented in this file.
## [0.45.14.0] - 2026-08-14
**The box that already has a brain: framework-spawned coding agents get brain access by default.** The bootstrap door built in v0.45.0.0 was for a human at a laptop. A growing share of Claude Code and Codex sessions are spawned by an agent framework — your OpenClaw, or anything that shells out to headless sessions — on a machine that already hosts a brain and a running `gbrain serve --http`. Until now those sessions got nothing unless someone hand-replicated settings writers across every project directory. One command fixes that:
gbrain bootstrap harness --yes
### Added
- **`gbrain bootstrap harness`** — machine-level wiring, no agent workspace or interview required. Mints a least-privilege bearer token, registers a user-scope HTTP MCP server for Claude Code, pre-approves its tools for headless runs (the `permissions.allow` gate that otherwise blocks `claude -p`), wires the five lifecycle hooks (user scope by default, or exactly the dirs you pass with repeatable `--project`), and writes Codex's config block directly — with the token inline, because framework-spawned codex inherits no shell profile for an env var to live in. Everything is stated before it happens (reach, plainly: read AND write, every session on the machine; transcript capture is its own consent line with `--no-capture` as its off-ramp), non-interactive runs require `--yes`, re-runs are idempotent, and `--remove` tears down exactly what the machine-level receipt records.
- **Scoped bearer tokens.** `gbrain auth create --scopes read,write` narrows a token to exactly those operations; tokens created without scopes keep their historical full access, byte for byte. The harness token uses this by default, and its reads span the brain's federated sources — the same reach a local session gets. `gbrain auth list` now shows each token's id and honest scope; `gbrain auth revoke --id <uuid>` revokes precisely one token (names were never unique). The admin dashboard shows real grants instead of assuming full access.
- **Safe rotation by construction.** Re-running harness wiring mints the new token first, wires and verifies everything, and only then revokes the previous token by id — a failed re-wire leaves the old credential fully working. A wiring crash at any step leaves a consumable receipt: `--remove` and `gbrain bootstrap uninstall` clean up partial states instead of stranding them.
- **`gbrain bootstrap harness --status`** probes the live truth — serve health, token validity (recovered from the host's own registration, redacted), per-target states, and honest degrades — with cron-friendly exit codes. `gbrain doctor` gains a `bootstrap_harness_health` check that distinguishes "serve is down (normal transient)" from "wiring incomplete" from "rotation never converged".
- **Honesty on Postgres brains.** Per-turn hook injection is PGLite-only today; harness mode says so plainly at install time, wires the hooks anyway (they light up when the engine-uniform listener lands), and names MCP as the active seam.
### Fixed
- A routine `gbrain auth permissions set-takes-holders` edit silently deleted a token's other stored grants (whole-object replace); it now merges — and resets rows whose stored grants were damaged by a historical encoding bug to a clean object instead of compounding them.
- Registration ownership on multi-brain machines: harness wiring refuses to replace an MCP registration that points at a different brain's serve without `--force`, and removal skips registrations it no longer owns — or whose ownership it cannot verify — instead of deleting another install's wiring.
- User-scope settings writers resolve Claude Code's config location the way Claude Code does (`CLAUDE_CONFIG_DIR`, then `$HOME`) — sandboxed environments previously risked writing to the operator's real settings file.
- Ship-review hardening (three adversarial passes at ship): the post-wiring verification now sends a deliberately invalid credential first — an endpoint that accepts it is not a real serve, wiring rolls back, and the fresh token is retired immediately on ANY failed verification; a failed verification also rolls a fresh Claude Code registration (and its headless pre-approval) back to the pre-run state; the pre-approval never lands when the registration itself failed; prior wiring is only cleaned up after the replacement verifies; `--status` and `gbrain doctor` report honestly on partially-applied or partially-removed installs instead of reading vacuously green, and `--status` only recovers a bearer from a registration it can verify as its own; token-scope reads fail closed on damaged rows across the verify and CLI display paths (`auth list` shows exactly what the serve enforces); a value-less `--project` or `--scopes` flag errors loudly instead of silently widening scope or minting a full-access token; settings writers refuse to rewrite permission policy shapes they don't understand; config writes serialize under cross-install locks on every path (apply, remove, cleanup, rollback); and per-turn hooks defer per-event to workspaces that carry their hook wiring in committed settings.
To take advantage of v0.45.14.0: upgrade, then on any agent-framework box run `gbrain bootstrap harness --yes` against your running `gbrain serve --http`. On PGLite brains, pre-mint with `gbrain auth create bootstrap-harness --scopes read,write` while the serve is stopped and pass `--token`. Restart your serve after upgrading so token scoping is enforced by the new verify path — the install says this too, exactly when it applies. See the "Local harness mode" section of docs/guides/bootstrap.md.
## [0.45.13.0] - 2026-08-13
**The Truthful Surface wave: your agent's MCP catalog now tells the truth. What's listed is callable, empty answers explain themselves, and new clients start with a focused ~26-tool surface they can widen on demand.**
This wave answers a production consumer's six-point review of the remote MCP
experience ("A- tools, B- packaging"). Every fix rewires signals gbrain already
computes into responses agents already receive — organized around one principle:
the tool catalog is an API promise.
### Added
- **Starter tool surface + pull-based unlock.** A new `starter` surface
(~26 daily-driver tools: the seven verbs plus the reviewed brain-tool
slice and the agent lane, re-derivable from your own production usage via
`scripts/derive-starter-ops.ts`) sits between `verbs` and `full`. Each connected client can carry its own surface, bounded
by the server ceiling — it can narrow itself or widen up to the ceiling with
the new `request_tools` tool, and operators can pin a client's surface with
`gbrain auth rescope-client` (pins beat self-service, always). Every surface
change writes an audit row, so "why did this client see 20 tools yesterday
and 100 today" is answerable from logs alone.
- **`request_tools` discovery meta-op.** No arguments → the catalog visible to
YOUR credentials, grouped by area with one-line summaries. `{tools: [...]}`
→ full schemas for named tools. `{surface}` → persist a wider or narrower
surface (rate-limited, ceiling-bounded, operator-pin-aware), then re-list.
- **Fail-loud retrieval.** Every `query`/`search` response now carries
`_meta.retrieval` — retrieved count, a closed-vocabulary `degraded[]` trail
(embed unavailable, expansion failed, budget truncated, ...), and a hint for
concept-shaped queries. Empty results additionally carry a model-visible
explanation block, and the CLI names the cause instead of a bare
"No results."
- **Synthesis that says why.** `synthesize` (and `think`) report
`synthesis_status`, pages/takes gathered, and typed warnings. When the LLM
compose step fails but retrieval found material, you now get an extractive
fallback answer built from the gathered pages instead of silence.
- **Minions queue visibility.** `get_status_snapshot` gains `queue` (per-queue
depth + oldest-waiting age) and `workers` (supervisor liveness) sections.
`submit_agent`/`submit_job` return `queue_state` with a warning when the
queue is backed up, paused, or has no live worker — a job ID alone is no
longer mistakable for progress. New `get_agent_job` op lets agent-scope
clients poll their own jobs (and only their own).
- **Lint visibility on writes.** `put_page` now returns the top lint findings
(errors first, with per-validator fix hints) instead of just counts.
- **Operator tooling.** `gbrain auth clients --usage` (per-client op usage),
a starter-fit advisor collector with drift detection, a generated
`docs/TOOL_CATALOG.md`, and a surface-operations runbook.
### Changed
- **The tool catalog is now honest on every transport.** `tools/list` reflects
what the caller can actually invoke: publish-gated, scope-blocked,
surface-hidden, and fence-blocked tools are unlisted rather than listed-then-
denied; local-only tools are confined to the local transport. Hidden and
nonexistent tools are indistinguishable on the wire.
- **Complete, guessable schemas.** Every network-visible tool parameter now
carries a description with examples (37 backfilled), CI-guarded so new params
can't ship undocumented. Unknown arguments get a did-you-mean response:
warn-and-accept by default this release (`mcp.strict_params`), with a named
flip to reject in a future minor.
- **Search degradation is survivable.** One failed embedding arm no longer
zeroes the whole vector fan-out — survivors are salvaged and stamped. A
first-result-exceeds-budget search now returns one truncated result instead
of an empty list, and the token cap is now a true hard cap.
- Degraded result sets cache for ~60s (stamped) instead of full TTL, so a
transient provider outage stops echoing for an hour. Cache keys fold the new
degradation stamp (one-time miss spike on upgrade).
### Fixed
- Request-log statuses now distinguish denials-after-list and warn-mode
successes on both HTTP transports, usage-derived features count only
successful calls, and the admin error-rate metric no longer counts audit
bookkeeping as traffic.
- Config read failures fail closed: a transient config outage can no longer
widen a client's surface or re-open the unknown-argument grace period on a
strict server.
- Raw exception text no longer rides MCP responses — degradation reasons and
warnings use closed code vocabularies; details go to server logs.
## To take advantage of v0.45.13.0
`gbrain upgrade` runs the schema migration automatically.
1. **Upgrade and check:**
```bash
gbrain upgrade
gbrain doctor
```
2. **See what your clients actually use, then right-size them:**
```bash
gbrain auth clients --usage
gbrain auth rescope-client <client-id> --surface starter
```
Existing clients keep their current (full) surface — nothing narrows on
upgrade. New clients can be defaulted with `mcp.default_surface_dcr`.
3. **Watch retrieval health:** `gbrain search stats` now breaks down empty
results by cause, and every `query`/`search` response carries
`_meta.retrieval`.
4. **Agents discover the rest themselves:** any client can call
`request_tools` to browse the full catalog and (within your ceiling)
widen its own surface.
## [0.45.12.0] - 2026-08-13
**Hermes joins the tested-install club: a real-binary harness now proves gbrain works inside Hermes, and `gbrain friction diff` tells you whether an install problem is the agent's or ours.**
GBrain has long said "works with OpenClaw and Hermes." For OpenClaw that claim was
backed by tests; for Hermes it was backed by hope. This release closes that gap with a
full end-to-end harness: a Hermes runner for the claw-test friction lab, a real-binary
"door" test that registers gbrain into an actual Hermes install over MCP and asks it to
recall a seeded fact, and a CI job that installs a pinned Hermes release and runs the
door on demand. Every Hermes CLI behavior the harness relies on was pinned by observing
a real install — the flag-order traps, the interactive prompts, the exit-code quirks —
and those observations ship as documentation so your own Hermes setup benefits too.
The live claw-test lane also got honest: it now stages the scenario workspace before the
agent starts and verifies real outcomes after it finishes, so an agent that does nothing
and exits cleanly finally FAILS the run instead of passing it. And with two runners in
the registry, the new `gbrain friction diff --base openclaw --compare hermes` turns
friction reports into a comparison instrument: pain unique to one agent is that agent's
contract problem; pain common to both is ours.
## To take advantage of v0.45.12.0
`gbrain upgrade` is enough — no schema migration.
1. **Running Hermes?** Wire gbrain in with one command (full guide at
`docs/mcp/HERMES.md`, including the non-obvious flag-order and prompt gotchas):
```bash
printf 'Y\n' | hermes mcp add gbrain --env GBRAIN_HOME=$HOME --connect-timeout 60 --command $(which gbrain) --args serve
hermes mcp test gbrain
```
2. **Want the friction lab on your own agent?**
```bash
gbrain claw-test --live --agent hermes # or --agent openclaw
gbrain friction diff --base openclaw --compare hermes
```
3. **If anything looks wrong,** file an issue at https://github.com/garrytan/gbrain/issues
with `gbrain doctor` output.
### Itemized changes
**Added**
- Hermes runner for the claw-test harness: `gbrain claw-test --live --agent hermes` drives a real Hermes install headlessly (`$HERMES_BIN` override supported; `--list-agents` shows availability for both runners).
- `gbrain friction diff --base <run-or-agent> --compare <run-or-agent>`: cross-agent friction comparison with unique-to-each and changed sections, count deltas, and a compatibility banner that warns when runs cover different scenarios or versions. `--json` for machines.
- Real-binary Hermes install door e2e (`test/e2e/install-real-hermes.serial.test.ts`): registers this checkout's gbrain into a hermetic Hermes home via a live MCP handshake (110 tools discovered), verifies both the CLI and direct-config registration surfaces, and proves recall of a seeded fact in a paid one-shot turn. Triple-gated so it can never burn tokens by accident.
- Label-gated `hermes-door` CI job: installs a digest-and-tag-pinned Hermes release, refuses to go green if nothing actually ran, and uploads scrubbed evidence on failure.
- Per-client MCP docs for Hermes (`docs/mcp/HERMES.md`) and OpenClaw (`docs/mcp/OPENCLAW.md`), plus a dev-facing pin of every observed Hermes CLI behavior (`docs/mcp/HERMES-CLI-PIN.md`) and an install snippet in `INSTALL_FOR_AGENTS.md`.
- Generic agent-workspace compatibility test pinning the documented "any repo with a workspace" install flow (detection, scaffold additivity, resolver health).
**Changed**
- claw-test live mode now stages the scenario before the agent runs (fresh-install: brain pages + routing file + init; upgrade: seed replay) and verifies outcomes after it exits — doctor health, a scenario-declared query returning results, expected files existing, and for upgrades a non-mutating schema-version probe that a do-nothing agent cannot satisfy.
- The brief handed to live agents now matches the current CLI exactly (extract argument shape, doctor status vocabulary), and bare `gbrain` inside a live run resolves to the harness's own binary via a per-run PATH shim.
- Every claw-test run opens and closes with a machine-readable marker carrying the agent name and scenario, so friction analytics can resolve runs by agent; scripted runs are now labeled `scripted` instead of borrowing an agent's name.
- Scenario oracle configuration is validated on load — misdeclared oracles fail loudly instead of silently not being enforced.
**Fixed**
- claw-test now works out of the box when gbrain runs from source (`bun run src/cli.ts`): child invocations resolve to a real gbrain launcher instead of the bun runtime itself, which previously made the default harness unusable outside compiled builds.
- Upgrade-scenario runs in BOTH modes fail loudly when the scenario ships no seed dump, instead of quietly initializing a current database and reporting an "upgrade" that never exercised a migration.
- Every harness child process now runs under a wall-clock timeout, and live-agent timeouts kill the agent's whole process tree — a hung child no longer wedges a run (or a CI job) forever.
- Agent-side friction entries now survive the run's tempdir cleanup: they merge into your friction log before the workspace is deleted, so `friction render` and `friction diff` finally see both halves of a live run.
- `claw-test --list-agents` no longer races CLI teardown; output is complete and ordered.
- Live runs keep the agent's gbrain children pointed at the run's own hermetic brain even when the surrounding shell exports a database-pointing environment variable — the harness's verification and the agent's work can no longer land in two different places.
- The test real-name guard now correctly distinguishes the public Hermes platform (documented and tested) from private deployment names (still banned).
## [0.45.11.0] - 2026-08-12
**The install now ends by telling you the two things that matter: you own the brain, and here's the first skill to run.** A working install used to finish on a health report and three tour prompts — technically complete, but a new user walked away without the two facts that make gbrain worth trusting and worth using. Now `gbrain bootstrap verify` ends with a hand-off: **what you own** (every memory is a markdown file in YOUR private GitHub repo — read it, take it to a second machine, delete it and the brain is gone; or the local-only variant with the one command that gives it a durable home) and **what to do next** (run the `cold-start` skill — say "fill my brain" and your agent imports your Gmail, calendar, and contacts through ClawVisor, an OAuth vault so the agent never holds raw tokens, or offline archives like Google Takeout, one consented phase at a time).
The structural fix underneath: `cold-start` — the skill designed exactly for "I just installed this, now what?" — was excluded from the downstream skill bundle, so the paste-in install audience it was written for could never scaffold it. It's now bundled, it's the #1 recommended skill (ahead of the book-mirror flagship, because every flagship skill only becomes magical once the brain holds your real life), and a new drift guard fails CI if any recommended skill ever becomes unscaffoldable again.
To take advantage of v0.45.11.0: existing installs can run `gbrain skillpack scaffold cold-start` and say "fill my brain"; fresh installs get the full hand-off automatically.
### Added
- **The verify hand-off block.** On PASS, `gbrain bootstrap verify` prints (and returns in `--json` as `handoff`) the ownership statement — with the actual repo URL, or the local-only variant pointing at `gbrain bootstrap repo` — followed by the cold-start next action. The runbook's Hand off section now instructs the installing agent to make both land ("say them plainly, confirm they landed") and to OFFER running cold-start on the spot.
- **`cold-start` ships in the downstream bundle** (61 skills) and leads the recommended set, so the post-install advisory, `gbrain advisor`, and `gbrain skillpack scaffold --all` all surface it. Its prior bundle exclusion ("host onboarding flow") predated the personal-agent bootstrap and was reversed deliberately.
- **Recommended-set drift guard**: every recommended slug must be scaffoldable from the plugin bundle — recommended-but-unscaffoldable is a dead-end call-to-action and now fails the suite.
### Changed
- README's Codex and Claude Code paths spell out the same two follow-ups after the click moment: ownership (markdown in a repo you own) and cold-start as the first skill, with ClawVisor named as the credential path and offline archives as the no-gateway alternative.
**Also in this release — the first-five-minutes DX wave** (re-versioned from an unpublished 0.45.9.0 after the release queue moved):
**The first five minutes stop making you think.** We built a real-terminal harness that drives the actual install the way a new user does — every picker, prompt, silence window, and line of copy — and then fixed what it surfaced. Keyless `gbrain init` used to dead-end at an error before it created anything; now it just works, keyless, and says so. A fresh brain used to scroll ~240 lines of internal migration names; now it prints one line. The success screen used to bury the one thing to do next under eight competing calls to action; now the copy-paste memory demo is the last, obvious thing on screen. And the "here's the magic" moment in the README now points at the trick that only a brain can do — tell it something, restart, ask for it back — instead of a question your identity files answer for free.
Under the hood: the upgrade nudge now compares the version you're actually running (a stale or foreign cache can't tell you to upgrade to something you already have), a broken settings file makes the installer stop and tell you rather than quietly replace it, and `gbrain init --supabase` fails loudly in a script instead of pretending it worked. Every fix landed with a test, and a two-model adversarial review pass (Claude + Codex) caught a cluster of follow-on issues in the fixes themselves — a keyless upgrade command that pointed at a rejected path, a compiled-binary detection that broke for renamed binaries — which are fixed here too.
To take advantage of v0.45.11.0: nothing to do — `gbrain self-upgrade` (or your next `gbrain` invocation's upgrade nudge) brings you current, and the improvements are all in the install/first-run path a new brain hits automatically.
### Added
- **A real-PTY DX exploration harness** (`test/helpers/tty-harness.ts` + `scripts/dx-explore.ts`). It spawns any CLI — gbrain, `claude`, `codex` — under a true pseudo-terminal, timestamps every output burst, and turns silence windows into a measurable stall report, so "the user stared at a frozen screen for nine seconds" is an artifact, not a hunch. A `drive` mode lets an agent steer a live TUI across separate tool calls. Developer instrument only; transcripts are gitignored and nothing in the shipped product depends on it.
### Changed
- **Keyless is now the default when you have no embedding key**, on both the interactive and scripted paths: `gbrain init` completes with a loud, honest "keyless mode — keyword search plus memory your agent writes; everything works" notice instead of exiting with an error. A near-miss key typo still fails loudly (so a fat-fingered `OPENAPI_API_KEY` isn't silently buried). Multiple keys auto-pick the canonical default rather than refusing.
- **Fresh-brain init prints one schema-setup line** instead of the full migration replay; upgrades keep the per-migration detail where it has diagnostic value (`GBRAIN_MIGRATE_VERBOSE=1` restores it).
- **The init success screen leads with one action** — the three-command memory demo, last on screen — with import/scale-up/health collapsed into a single terse footer and the recommended-skills advisory reduced to a human-voiced pointer.
- **The provider picker offers "continue keyless" explicitly** and probe-gates a local Ollama daemon (a running daemon that hasn't pulled the model is annotated, not silently selected); a bare Enter never picks a broken local provider.
- **The upgrade nudge tells the truth about your binary**: it compares the running version to the latest and prints the running version, so a stale or foreign-written cache can't nag about an upgrade you already have. The raw machine marker stays off an interactive human's screen (override with `GBRAIN_FORCE_UPGRADE_MARKER=1` for PTY-based agent harnesses that parse it).
- **Copy honesty pass**: provider capabilities are attributed per provider (OpenAI unlocks semantic search + fact extraction; Voyage semantic search; Anthropic fact extraction — it has no embeddings API); the install-time estimate reads ~15 minutes for the personal-agent path (~30 for the always-on setup); the first-run tour says to restart first and frames the genuine cross-session round-trip.
### Fixed
- **A parse-broken `.claude/settings.local.json` aborts the hooks write** with a fix-and-re-run message instead of being replaced — your permissions and allowlist are never silently dropped.
- **`gbrain init --supabase` in a non-interactive shell fails loudly** (exit 1, names the `--url` escape hatch) instead of the old silent exit-0 that wrote no config.
- **`gbrain bootstrap hooks` with a missing harness CLI** now still installs per-turn hooks and reports the phase as partial (so a resuming agent re-runs it once the CLI is on PATH) instead of leaving a false "wire complete".
- **`gbrain bootstrap interview --set/--skip` after a confirmation** warns that it voided the read-back instead of failing silently later at render.
- Review-pass self-fixes: the keyless upgrade hint now names the re-init command that actually works (not the schema-sizing field `config set` rejects); compiled-binary detection for the detached update refresh no longer breaks for a renamed/official-named binary; the DX harness scrubs copied credentials even on interrupt and reaps the child's whole process tree.
## [0.45.10.0] - 2026-08-13
**21 more community and maintainer bug fixes. Search answers get more complete, sync gets safer, and doctor learns to warn you before a provider dies.**
This wave continues the v0.45.8.0 cleanup: no new product surface, just fixes. The
standouts: pages created by the idea-extraction cycle were invisible to search (they
were written without search chunks) and now show up like everything else, with a repair
path for existing brains. Query caching now keys on your detail setting, so a compact
answer is never served to a full-detail request. And doctor now warns you loudly if your
brain is pinned to an embedding provider that has announced a shutdown, weeks before it
happens instead of after.
Also riding: the rerank budget fix that landed directly this week. Contributed by @javieraldape.
## To take advantage of v0.45.10.0
`gbrain upgrade` is enough. No schema migration.
1. **Upgrade and check:**
```bash
gbrain upgrade
gbrain doctor
```
2. **If doctor now warns about your embedding provider,** that is the new sunset check
doing its job. It names the provider, the date, and the migration command.
3. **Heal previously-invisible atom pages:**
```bash
gbrain embed --stale
```
4. **Things to watch:** the query cache key version moved, so the first re-ask of a
cached question is a one-time cache miss. If anything else looks wrong, file an issue
with `gbrain doctor` output: https://github.com/garrytan/gbrain/issues
### Itemized changes
**Search and recall**
- Atom pages produced by the extraction cycle are chunked and embedded like every other page, so they appear in search results. Contributed by @awilhite.
- `embed --stale` detects and heals pages that have content but no chunks. Contributed by @Masashi-Ono0611.
- The query cache folds the detail knob into its key, so compact and full-detail answers never cross. Contributed by @time-attack.
- Rerank budget failures are bucketed under their real cause instead of "unknown". Contributed by @javieraldape.
**Sync, import, and write-through**
- Deferred link extraction above the size gate is consumed instead of dropped. Contributed by @time-attack.
- Import error summaries name the failing table and constraint. Contributed by @bo-developing.
- Write-through honors the page's recorded source path instead of recomputing it. Contributed by @JonMcCutchen.
- The managed filing-rules block renders each repo's own taxonomy, not the bundled default. Contributed by @dovstern.
- Timeline extraction no longer splits on bare hyphens inside link labels. Contributed by @time-attack.
- Export scopes tag and raw-data sidecar reads to the page's source. Contributed by @alexey-metaengage.
- Cross-source link targets survive an engine migration. Contributed by @RerankerGuo.
**Doctor and diagnostics**
- A damaged PGLite store is reported as store damage, with runtime problems kept separate, and the verdict requires positive evidence. Contributed by @time-attack.
- New check: brains pinned to an embedding provider with an announced shutdown get a loud warning with the migration path. Contributed by @time-attack.
- Source listing distinguishes unset federation from explicit false. Contributed by @dovstern.
- `put_page` reports push state honestly instead of implying success. Contributed by @dovstern.
- Flow-style skill triggers parse correctly in skill health checks. Contributed by @RerankerGuo.
- Sync-failure records auto-skipped as chronic stay visible to doctor until a human resolves them. Contributed by @RerankerGuo.
**Autopilot and agents**
- The drain worker no longer self-deadlocks at concurrency=1, and its DB reconnect logic is shared with queue operations. Contributed by @time-attack.
- Stale-lock reaping ignores foreign PIDs it did not create. Contributed by @javieraldape.
- Agent jobs resolve their brain source at submit time, not execution time. Contributed by @Masashi-Ono0611.
**OAuth**
- Dynamic client registration accepts `token_ttl_seconds`, clamped to admin policy, and an unset TTL cap now derives from `--token-ttl` instead of a permissive default. Contributed by @time-attack.
**Models**
- The claude-cli recipe lists the Claude 5 family ids the CLI already serves, with pins. Contributed by @clement0909472.
**For contributors**
- The CLI flag registry, one wave rider test, and the bootstrap version stamps were refreshed as part of assembly.
## [0.45.9.0] - 2026-08-12
**Your agent's memory keeps saving itself — even in a cloud sandbox, even on `/exit`, and it tells you the moment it can't.** The paste-in personal-agent install now works first-class in Claude Code's cloud environment, not just on a laptop. The persistence lane got three fixes that matter whether you're local or in the cloud: the workspace push now verifies repo privacy through a portable ladder that keeps working when the sandbox blocks the GitHub API, it runs after every turn (not only at session end, which the harness never fires on `/exit`), and a failed push surfaces on your next turn instead of failing in silence. Setup adapts to where it runs — no more scheduled-job errors on hosts without a scheduler, and no half-created repos in an environment that can't push them.
To take advantage of v0.45.9.0: upgrade and re-run `gbrain bootstrap verify` on each machine — it re-attests the install and now reports the execution environment and any push-health or hygiene issue with the exact one-line fix. Existing installs pick up the per-turn push and the new verification automatically on the binary update; no re-render needed. If you run in a cloud sandbox, `gbrain bootstrap cloud-setup-script` prints the environment setup recipe, and `gbrain bootstrap status --json` now tells you which environment you're in.
### Added
- **Execution-environment detection**`local`, `cloud-sandbox`, or `ephemeral-container`. Bootstrap, the doctor, and the runbook branch on it so each environment gets honest behavior and honest messages. `gbrain bootstrap status --json` and `gbrain bootstrap verify` both report it.
- **Per-turn workspace persistence.** A debounced, detached push runs after each assistant turn (default every 5 minutes locally, every turn in a reclaimed-VM cloud sandbox), closing the gap where a session that ends on `/exit` — which never fires the session-end hook — could strand committed work. Off-ramp: `GBRAIN_STOP_PUSH=0`; cadence: `GBRAIN_STOP_PUSH_DEBOUNCE_MIN` or `gbrain config set hooks.stop_push_debounce_min <n>`.
- **Same-session push-failure notice.** When a background push is refused or fails, the next turn surfaces it both to the agent and to you directly (not buried where only the model sees it), re-announced at most every 30 minutes until it clears. `gbrain doctor` and `gbrain bootstrap status` name the failing workspace and the fix.
- **`gbrain bootstrap cloud-setup-script`** — prints the ready-to-paste cloud environment setup script that installs the gbrain binary into the environment's cached filesystem so it survives across sessions.
- **`bootstrap_durability_job` doctor check** — presence *and* liveness of the optional background-persistence job, so a job that exists on disk but no longer runs is reported instead of certified healthy.
### Changed
- **Repo-privacy verification is now a portable ladder** (`src/core/repo-visibility.ts`), replacing three separate probes with one: it checks via the GitHub REST API first, then falls back to pure git protocol so verification keeps working where a sandbox proxy blocks the API. It fails closed in both directions — an origin that can't be proven private is refused, and a proven-public origin is always refused. Fresh private verdicts are cached briefly to keep the per-turn push cheap. Escape hatch for self-hosted git you trust (each use warns): `--allow-unverified-remote`, `GBRAIN_ALLOW_UNVERIFIED_REMOTE=1`, or `gbrain config set push.allow_unverified_remote true`; the escape hatch only relaxes an *unverifiable* verdict, never a proven-public one.
- **Cloud sandboxes get a committed hook carrier.** Because a cloud session starts from a fresh clone and never sees the machine-local settings file, cloud installs write hooks into the repo-committed `.claude/settings.json` with a PATH-resolved, fail-open command; local installs keep the gitignored settings file, and the writers guarantee one event never fires from both.
- **Background-persistence copy tells the truth.** The optional job is a git post-commit auto-push plus a 30-minute freshness pull; the interview, docs, and templates now describe exactly that. On a host without a scheduler the pull is skipped with an honest note rather than a failed-install warning.
- The installing-agent runbook gains a hard rule against fabricating tooling (no hand-rolled `gh` shims), a cloud-sandbox section, and the honest degradation matrix for a proxied environment.
### Fixed
- `gbrain bootstrap uninstall` now tears down the background-persistence wiring it installed (scheduled job, the untracked auto-push hook, credential wiring) instead of leaving it behind; the committed helper and agent-rules stay, since those are your repo's content.
- Machine-specific harness wiring (`.mcp.json`, hook-settings backups) is gitignored so it can't be committed into the private brain repo; `gbrain bootstrap verify` warns and gives the one-line fix for installs that already committed it.
- Repo creation is refused inside a cloud sandbox with the flow that actually works (create the repo elsewhere, open the session on it, `gbrain bootstrap attach`) instead of leaving a half-created, unpushable repo.
- Push-status is tracked per workspace, so with more than one brain workspace on a machine, one workspace's success can no longer mask another's failed pushes.
- Hardening pass (both an in-house and a cross-model adversarial review): the privacy ladder never treats an ambiguous authentication challenge as proof a repo is private, the per-turn retry can't turn into an every-turn network storm, remote-supplied text is sanitized before it reaches any agent- or user-visible surface, and stale state from a deleted workspace no longer re-fires notices forever.
## [0.45.8.0] - 2026-08-12
**25 community bug fixes in one wave. Your MCP server, sync, and doctor all get more careful.**
This release is all fixes, no new surface. 24 community contributors sent small, tested
bug fixes over the past weeks. Each one was reviewed, tested in isolation against a real
checkout, checked by an adversarial second reviewer, security reviewed, and then tested
again as one combined branch. The themes: the MCP server now handles edge-case inputs
the way an agent expects, sync and import stop losing or misplacing data in rare
situations, and doctor stops crying wolf on healthy setups.
If you connect an agent to gbrain over MCP, or you sync a brain repo with unusual file
names, non-English content, or multiple sources, this release removes a set of paper
cuts you may have already hit.
## To take advantage of v0.45.8.0
`gbrain upgrade` is enough. These are behavior fixes with no schema migration.
1. **Upgrade and verify:**
```bash
gbrain upgrade
gbrain doctor
```
2. **If doctor output changed for you,** that is likely the point: several checks
(supervisor, PGLite store health, base-URL hints) now report accurately where they
previously false-alarmed.
3. **If anything looks wrong,** file an issue at https://github.com/garrytan/gbrain/issues
with `gbrain doctor` output.
### Itemized changes
**MCP server correctness**
- `sources_add` over a remote transport now rejects a caller-supplied path outright instead of silently ignoring it. Contributed by @gregario.
- Stdio serve advertises the tools the caller can actually use. Contributed by @gregario.
- All stdout logging routes to stderr under stdio MCP, keeping the protocol stream clean. Contributed by @BenSheridanEdwards.
- Null and empty-string optional params are treated as absent at dispatch. Contributed by @SeanGearin.
- File ops (`file_list`, `file_upload`) use the connected engine instead of the global DB singleton, so they work on every configured engine. Contributed by @dpaluy.
- Stdio serve honors the `.gbrain-source` dotfile. Contributed by @javieraldape.
**Sync and import data safety**
- Global sync anchors only move for the brain repo they describe, so a second repo can no longer skip another repo's pending imports. Contributed by @smdesai27.
- Sync never writes a baseline commit over an already-populated repo. Contributed by @NidTamil.
- Git C-style-quoted paths (quotes, backslashes, unicode escapes) unquote correctly in the sync manifest. Contributed by @SergeyShol.
- Malformed YAML frontmatter is rejected with a clear error instead of importing garbage. Contributed by @javieraldape.
- Paths that fail once but succeed on a later run clear their failure record. Contributed by @bo-developing.
- Autopilot resolves the gbrain CLI on Windows via PATH enumeration instead of assuming a POSIX shell. Contributed by @veltri-23.
- Ctrl-C cleanly terminates bulk commands using the shared progress reporter. Contributed by @javieraldape.
**Engines and search**
- PGLite batches code-edge inserts below the bind-parameter limit, fixing silent data loss on large code graphs. Contributed by @kyle944.
- The configured FTS language survives schema replay, so non-English brains no longer revert to English tokenization on re-init. Contributed by @paul-0320.
- Hyphenated Qwen3-Embedding model ids resolve their dimensions correctly. Contributed by @mikez93.
**Doctor and diagnostics**
- Doctor surfaces abandoned PGLite stores left behind after an engine migration. Contributed by @Masashi-Ono0611.
- The base-URL hint uses a real models-probe classifier instead of guessing /v1. Contributed by @brettdavies.
**Models and cycle**
- Sonnet 5, Fable 5, and Opus 4.8 are in the synthesize context map and brainstorm output caps. Contributed by @p3ob7o.
- Truncated or degenerate significance verdicts are no longer cached permanently. Contributed by @Masashi-Ono0611.
- The `models.subagent` config path goes through the same capability checks as every other model path. Contributed by @Masashi-Ono0611.
- `takes add` resolves the target page before writing markdown. Contributed by @ghizi.
- A shipped filing rule that bound a personal folder name to a sensitive category is gone. Contributed by @Masashi-Ono0611.
- BrainBench eval defaults resolve from the package root, so evals run from any working directory. Contributed by @philip-rossoneri.
- The OpenClaw plugin-loader E2E inspects the real runtime. Contributed by @arisgysel-design.
**For contributors**
- The committed CLI flag registry, the cycle-sync test mocks, and two test fixtures were updated to match the combined branch.
## [0.45.7.0] - 2026-08-12
**Ambient recall: your brain shows up at the moments that matter, not just when you ask.** Long-lived agents lose the thread at session boundaries — a fresh start with no warm context, a compaction that drops verbatim detail nothing rehydrates, a heartbeat that re-derives state from scratch. This release adds two new memory verbs that assemble a budget-packed, zero-LLM bundle of exactly what a boundary needs, and wires them into the agent's lifecycle hooks so a warm pack lands automatically at session start and after compaction. It's opt-in, fail-open, and reaches every host: Claude Code gets it pushed through hooks; Codex and any MCP host pull the same two verbs at their own boundaries. Whether your brain is embedded (PGLite) or managed (Postgres), the ambient value is the same.
@@ -245,7 +640,7 @@ answers. Ask before anything destructive. You are not done until
`gbrain bootstrap verify` exits 0.
```
The agent runs `gbrain bootstrap` — a new command family (`status`, `interview`, `render`, `repo`, `hooks`, `verify`, `attach`, `uninstall`) that drives the whole install. It works with **zero API keys**: your harness's model is the LLM, so the agent authors memory directly and search runs keyword-only; add one optional key (OpenAI, Anthropic, or Voyage) to unlock semantic search and automatic fact extraction. Everything is consent-gated — hooks, background push, MCP scope — and nothing runs while your harness is closed (the honest desktop contract; true 24/7 is what a hosted brain adds).
The agent runs `gbrain bootstrap` — a new command family (`status`, `interview`, `render`, `repo`, `hooks`, `verify`, `attach`, `uninstall`) that drives the whole install. It works with **zero API keys**: your harness's model is the LLM, so the agent authors memory directly and search runs keyword-only; add one optional key to upgrade capabilities (OpenAI: semantic search + automatic fact extraction; Voyage: semantic search; Anthropic: fact extraction). Everything is consent-gated — hooks, background push, MCP scope — and nothing runs while your harness is closed (the honest desktop contract; true 24/7 is what a hosted brain adds).
### What you get
@@ -16803,8 +17198,6 @@ If anything looks off, file at https://github.com/garrytan/gbrain/issues
with `gbrain doctor` output.
## [0.28.11] - 2026-05-07
**Mix providers: OpenAI for text, Voyage for images. One brain, two embedding pipelines.**
@@ -18770,9 +19163,6 @@ React admin dashboard baked into the binary. Seven screens designed through Stev
- `test/oauth.test.ts` ... 34 test cases covering provider: register, getClient, client_credentials exchange, auth_code flow with PKCE, refresh rotation, verifyAccessToken (OAuth + legacy fallback), revokeToken, sweepExpiredTokens, scope annotations on all 30 operations. Plus the post-/cso security-fix regressions: 10-concurrent auth code exchange (only 1 wins), 10-concurrent refresh rotation (only 1 wins), redirect_uri HTTPS-or-loopback gate, and pgArray comma-element round-trip (1 element in → 1 element out).
## [0.25.1] - 2026-05-01
## **Your brain can now read books with you. Nine new skills land at once.**
@@ -20022,7 +20412,6 @@ Then point Claude Desktop, claude.ai/code, or any MCP client at `http://your-tun
If anything breaks: `gbrain doctor`, `~/.gbrain/upgrade-errors.jsonl` (if present), and please file an issue at https://github.com/garrytan/gbrain/issues with both.
## [0.22.6.1] - 2026-04-26
**Old brains can upgrade again.**
+3 -1
View File
@@ -506,7 +506,9 @@ four numeric segments are required first. Historical 3-segment versions
| `CHANGELOG.md` | Top entry header `## [0.31.4.1] - YYYY-MM-DD` plus the "To take advantage of v0.31.4.1" block. | Standard Keep-a-Changelog header. |
| `TODOS.md` | Any TODO entries that mention "follow-up from vX.Y.Z.W" use the version of the release that filed them. Update only when filing NEW follow-up TODOs. | Inline `vX.Y.Z.W` references in TODO bodies. |
| `CLAUDE.md` | The Key Files section's per-file annotations carry `vX.Y.Z.W (#NNN)` tags noting which release introduced a behavior. Update whenever a wave's annotations get folded in. | Inline `vX.Y.Z.W (#NNN, contributed by @user)` references. |
| `openclaw.plugin.json` | OpenClaw plugin manifest (v0.45.6.0, #4033). Hand-maintained; `test/openclaw-plugin-manifest.test.ts` fails the suite if it drifts from `package.json`. Merges from master auto-resolve it to master's version — re-bump it with the trio. | `"version": "0.45.7.0"` |
| `openclaw.plugin.json` | OpenClaw plugin manifest (v0.45.6.0, #4033). Hand-maintained; `test/openclaw-plugin-manifest.test.ts` fails the suite if it drifts from `package.json`. Merges from master auto-resolve it to master's version — re-bump it with the trio. | `"version": "0.45.12.0"` |
| `BOOTSTRAP_FOR_AGENTS.md` | Runbook stamp on line 1. `scripts/check-bootstrap-tag.sh` (in `bun run verify` + CI) fails when it drifts from `VERSION`; refresh it in the same commit as the bump. | `<!-- gbrain-runbook-stamp: X.Y.Z.W -->` |
| `templates/bootstrap/template-repo/` | Vendored template tree with an embedded version stamp. Auto-derived, but NOT by `bun install`: run `bun run scripts/generate-template-repo.ts --out templates/bootstrap/template-repo` after the bump; `scripts/check-bootstrap-templates.sh` fails CI on drift. | `<!-- gbrain-template-stamp: X.Y.Z.W -->` in generated files. |
**Auto-derived (no manual edit; refreshed by their own commands):**
+14 -3
View File
@@ -90,9 +90,10 @@ NOT inside ~/gbrain.
## Step 3.5: Confirm search mode with the user (DO NOT SKIP)
`gbrain init` auto-applied a default search mode (`tokenmax` unless your subagent
tier is Haiku-class or no OpenAI key is configured). The init output included the
cost matrix below preceded by `[AGENT]` markers. You must NOT silently accept the
default. Stop and ask the operator.
tier is Haiku-class or no expansion-capable API key — Anthropic, OpenAI, or
Google — is configured). The init output included the cost matrix below preceded
by `[AGENT]` markers. You must NOT silently accept the default. Stop and ask the
operator.
**Present this matrix verbatim:**
@@ -216,6 +217,16 @@ diff against gbrain's bundle when you want upstream improvements. (The legacy
`gbrain skillpack install` managed-block model was removed in v0.33 — run
`gbrain skillpack migrate-fence` once if upgrading from an older release.)
**If you are Hermes:** register gbrain as your MCP server:
```bash
printf 'Y\n' | hermes mcp add gbrain --env GBRAIN_HOME=$HOME --connect-timeout 60 --command $(which gbrain) --args serve
```
Keep `--args` last (everything after it becomes server argv) and verify with
`hermes mcp test gbrain` — the add exits 0 even on failure. Full reference:
[docs/mcp/HERMES.md](docs/mcp/HERMES.md).
Whether you scaffolded or not, read `skills/RESOLVER.md` (in your workspace, or the
bundled copy at `~/gbrain/skills/RESOLVER.md` when running from the cloned repo). It's
the skill dispatcher — tells you which skill to read for any task. Save this to your
+9 -5
View File
@@ -15,7 +15,7 @@ The point of building a 150K-page brain is to use it as a strategic moat. To nev
It's easier to ship a daemon that runs 24/7 to ingest, enrich, and consolidate than it is to keep an agent in chat working hard. GBrain is that daemon, generalized. Install in 30 minutes. Your agent does the work. As my personal agent gets smarter, so does yours.
> **~30 minutes to a fully working brain.** Database ready in 2 seconds (PGLite, no server). You just answer questions about API keys.
> **~15 minutes to a working personal agent** on the recommended Codex / Claude Code path (mostly a short interview); ~30 minutes for the always-on OpenClaw / Hermes setup. Database ready in 2 seconds either way (PGLite, no server).
> **LLMs:** fetch [`llms.txt`](llms.txt) for the documentation map, or [`llms-full.txt`](llms-full.txt) for the same map with core docs inlined in one fetch. **Agents:** start with [`AGENTS.md`](AGENTS.md) (or [`CLAUDE.md`](CLAUDE.md) if you're Claude Code).
@@ -90,7 +90,9 @@ answers. Ask before anything destructive. You are not done until
`gbrain bootstrap verify` exits 0.
```
Codex will ask for command approvals during the install — approving them is the sandbox working as intended. What you get, in about 15 minutes: a short interview (6 required questions) → your agent's identity (SOUL.md, USER.md, MEMORY.md) rendered from your own answers, never invented → a local PGLite brain (2 seconds, no server, no Docker) → MCP wired so every session can search and write memory → a **private** GitHub repo, created and privacy-verified, as your agent's durable body. Works with **zero API keys** — keyword search plus memory your agent writes itself; one optional key (OpenAI, Anthropic, or Voyage) upgrades to semantic search and automatic fact extraction. Codex reads brain context through its tools each turn (pull-based).
Codex will ask for command approvals during the install — approving them is the sandbox working as intended. What you get, in about 15 minutes: a short interview (6 required questions) → your agent's identity (SOUL.md, USER.md, MEMORY.md) rendered from your own answers, never invented → a local PGLite brain (2 seconds, no server, no Docker) → MCP wired so every session can search and write memory → a **private** GitHub repo, created and privacy-verified, as your agent's durable body. Works with **zero API keys** — keyword search plus memory your agent writes itself; one optional key upgrades capabilities (OpenAI: semantic search + automatic fact extraction; Voyage: semantic search; Anthropic: fact extraction). Codex reads brain context through its tools each turn (pull-based). The click moment: tell it one small thing to remember, restart Codex, then ask for it back — the answer comes from the brain, not from this chat's context (which the restart cleared). That cross-session round-trip is the whole product; "what's my name / my top jobs?" is answered from your identity files, which is nice but not the same trick.
Two things worth understanding once it's running: **you own the brain** — every memory is a markdown file in that private repo (read it, clone it to a second machine, delete it and the brain is gone) — and **the first skill to run is `cold-start`**: say "fill my brain" and your agent imports your Gmail, calendar, and contacts (via [ClawVisor](https://clawvisor.com), an OAuth vault so the agent never holds raw tokens) or offline archives like Google Takeout, one consented step at a time. An empty brain is a database; a filled one is a memory.
> **Prefer to make the repo yourself?** Create a new **empty** private repo **under your own GitHub account** (no README/.gitignore/license), clone it, open the clone in Codex, and paste the same block — bootstrap detects your empty repo and adopts it instead of creating one. The repo must be empty and personal-account-owned; org-owned repos are refused (create one under your account, or let bootstrap make it).
@@ -107,7 +109,7 @@ answers. Ask before anything destructive. You are not done until
`gbrain bootstrap verify` exits 0.
```
Everything from the Codex path applies — interview, identity from your own answers, local brain, private repo, keyless mode — plus Claude Code gets **per-turn context hooks** (consent-gated): your brain loads automatically into every prompt, and each session persists itself to your private repo at exit. Restart the session after install and ask "what did I tell you my top jobs were?" — that's the moment it clicks. Full contract, security posture, and uninstall: [docs/guides/bootstrap.md](docs/guides/bootstrap.md).
Everything from the Codex path applies — interview, identity from your own answers, local brain, private repo, keyless mode — plus Claude Code gets **per-turn context hooks** (on by default, with an opt-out): your brain loads automatically into every prompt, and your work persists to your private repo on a per-turn cadence (debounced ~5 min locally, every turn in a cloud sandbox — this covers the `/exit` case the harness never fires a session-end hook on), with a notice on your next turn if a push ever fails. This works in a **Claude Code cloud session** too, not just on your laptop: verification falls back to pure git protocol when the sandbox blocks the GitHub API, and `gbrain bootstrap cloud-setup-script` prints the environment setup recipe. The click moment: tell it one small thing to remember, restart the session, then ask for it back — a fresh session has no chat context, so the answer can only come from the brain. That cross-session round-trip is the whole product ("what's my name?" is answered from your identity files — nice, but not the same trick). Same two follow-ups as the Codex path: you own the brain (markdown in your private repo), and `cold-start` is the first skill to run — "fill my brain" imports your email, calendar, and contacts (ClawVisor) or offline archives, one consented step at a time. Full contract, security posture, cloud sandboxes, and uninstall: [docs/guides/bootstrap.md](docs/guides/bootstrap.md).
> **Prefer to make the repo yourself?** Create a new **empty** private repo **under your own GitHub account** (no README/.gitignore/license), clone it, open the clone in Claude Code (CLI or the desktop app's open-a-repo flow), and paste the same block — bootstrap adopts your empty repo instead of creating one. The repo must be empty and personal-account-owned; org-owned repos are refused.
@@ -131,7 +133,7 @@ The agent installs GBrain, creates the brain, asks for your API keys, loads the
### Lighter ways in
**Just want a memory for your coding agent — no identity, no repo.** Spin up a local brain and connect it in two commands — zero server, zero token, zero tunnel. `--surface verbs` gives your agent the five-verb memory protocol (`recall`, `remember`, `entity`, `synthesize`, `forget` — [MEMORY_VERBS v1](docs/protocol/MEMORY_VERBS_v1.md), frozen + additive-forever) instead of the full tool wall; drop the flag for every operation:
**Just want a memory for your coding agent — no identity, no repo.** Spin up a local brain and connect it in two commands — zero server, zero token, zero tunnel. `--surface verbs` gives your agent the seven-verb memory protocol (`recall`, `remember`, `entity`, `synthesize`, `forget`, plus `context_pack` + `delta` since v0.45.7 — [MEMORY_VERBS v1](docs/protocol/MEMORY_VERBS_v1.md), frozen + additive-forever) instead of the full tool wall; drop the flag for every operation:
```bash
gbrain init --pglite # 2-second local brain (no Docker)
@@ -170,6 +172,8 @@ GBrain exposes nearly all of its 100+ operations as MCP tools (stdio and HTTP; a
- **[Claude Code](docs/mcp/CLAUDE_CODE.md)** — local: one command, `claude mcp add gbrain -- gbrain serve` (zero server, zero tunnel). Remote with just a bearer token: `gbrain connect https://your-host/mcp --token gbrain_xxx` prints a paste-ready block (or `--install` wires it up and smoke-tests the token).
- **[Codex](docs/mcp/CODEX.md)** — `gbrain connect https://your-host/mcp --token gbrain_xxx --agent codex` (or `--install`). Codex reads the bearer from `$GBRAIN_REMOTE_TOKEN` at runtime, so the token never lands in Codex config.
- **[Cursor / Windsurf / any stdio MCP client](docs/mcp/CLAUDE_CODE.md)** — same shape, add `{"command": "gbrain", "args": ["serve"]}` to your MCP config.
- **[Hermes](docs/mcp/HERMES.md)** — `printf 'Y\n' | hermes mcp add gbrain --env GBRAIN_HOME=$HOME --connect-timeout 60 --command $(which gbrain) --args serve`. Keep `--args` last, and verify with `hermes mcp test gbrain` (the add exits 0 even on failure).
- **[OpenClaw](docs/mcp/OPENCLAW.md)** — the ClawHub bundle plugin registers gbrain automatically (`openclaw.plugin.json` ships in this repo), or add `{"command": "gbrain", "args": ["serve"]}` to `~/.openclaw/config.json`'s `mcpServers`.
- **[Claude Desktop (Cowork)](docs/mcp/CLAUDE_DESKTOP.md)** — Settings → Integrations → add the URL of your HTTP server. Remote only; the local `claude_desktop_config.json` does not work for remote servers.
- **[Claude Cowork (team plan)](docs/mcp/CLAUDE_COWORK.md)** — org Owner adds the connector under Organization Settings → Connectors.
- **[Perplexity Computer](docs/mcp/PERPLEXITY.md)** — `gbrain connect https://your-host/mcp --agent perplexity --oauth --register` mints a least-privilege OAuth client and prints the Issuer/Client ID/Secret to paste into Settings → Connectors (OAuth is the right path for a cloud connector; a bearer token also works for local use). Pro subscription required.
@@ -462,7 +466,7 @@ the page PK, soft-delete-filtered, source-safe) and completes in seconds.
## Docs
- [`docs/INSTALL.md`](docs/INSTALL.md) — every install path, end to end
- [`docs/guides/bootstrap.md`](docs/guides/bootstrap.md) — the persistent-personal-agent bootstrap contract (interview, identity files, hooks, private repo, security posture, uninstall)
- [`docs/guides/bootstrap.md`](docs/guides/bootstrap.md) — the persistent-personal-agent bootstrap contract (interview, identity files, hooks, private repo, security posture, uninstall), plus local harness mode (`gbrain bootstrap harness`) for wiring framework-spawned Claude Code/Codex sessions to a running serve
- [`docs/what-schemas-unlock.md`](docs/what-schemas-unlock.md) — why schemas matter: 7 killer use cases, the structural argument for typed page kinds, the agent-co-curates pattern (v0.40.7.0)
- [`docs/schema-author-tutorial.md`](docs/schema-author-tutorial.md) — 5-minute walkthrough: fork the bundled pack, add a custom type, backfill existing pages, prove the wiring via `gbrain whoknows`
- [`docs/architecture/`](docs/architecture/) — system design, topologies, retrieval theory
+429 -15
View File
@@ -1,5 +1,214 @@
# TODOS
## Truthful-surface wave follow-ups (filed with T14, amendment 35 + D14.5)
Deferred from the MCP consumer-feedback wave (plan at
`~/.claude/plans/system-instruction-you-are-working-snuggly-parrot.md`; scoped
OUT deliberately — see the plan's "NOT in scope" list).
- [ ] **P1 — strict_params reject-flip.** **What:** flip the `mcp.strict_params`
default from `warn` to `reject`. **Why:** the warn grace period exists so
clients adapt before unknown args become hard errors; leaving it warn forever
re-opens the silent-arg-typo class WP3 closed. **Context:** named flip
criterion — ZERO `success_with_warnings` rows over 30 days of production
traffic (`SELECT count(*) FROM mcp_request_log WHERE
status='success_with_warnings' AND created_at > now() - interval '30 days'`;
see docs/operations/mcp-surface-runbook.md Move 3). The flip is a config
DEFAULT change in `resolveStrictParamsMode` + the `additionalProperties:
false` emission becoming the default tools/list shape; the pinned
default=warn test to update is `test/validate-params.test.ts` ("unresolved
(absent) config defaults to warn") and `test/mcp-tool-defs.test.ts` pins
both emission states. **Effort:** small (1-line default + test updates).
**Priority:** P1.
- [ ] **P2 — mcp_request_log retention/pruning.** **What:** a retention policy
(age- or row-capped prune, `gbrain maintain` hook or cron). **Why:** the
table now carries MORE than request telemetry — `surface_change` audit rows
(ENG-8) and `denied_after_list` metric rows ride it — and it grows unbounded
on busy brains. **Context:** pruning must NOT silently discard the audit
trail — either exempt `operation='surface_change'` or archive before delete;
the usage reader (src/core/mcp-usage.ts) windows at ≤3650d. **Effort:**
medium. **Priority:** P2.
- [ ] **P3 — describe_tools op.** **What:** a dedicated per-op schema
introspection op (design OQ4). **Why:** deferred — `request_tools`' no-arg
catalog + complete tools/list schemas + did-you-mean on unknown tools/params
cover the need. **Context:** revisit if a consumer asks for schema detail
beyond what tools/list carries. **Effort:** small. **Priority:** P3.
- [ ] **P3 — page_lint pull op.** **What:** an op returning the FULL lint
report for a slug (design OQ5). **Why:** deferred — `put_page`'s inline
`writer_lint.top_findings` (top 5, errors first) suffices until someone
needs more than five findings or lint-without-write. **Context:** the
validator registry + FIX_HINTS (src/core/validators/index.ts) already
expose everything a pull op would need. **Effort:** small. **Priority:** P3.
- [ ] **P3 — named client tiers.** **What:** Phase 2 of the per-client surface:
named tiers (e.g. 'analyst', 'writer') stored in the SAME
`oauth_clients.surface` column. **Why:** teams want role-shaped catalogs,
not just the 3-step ladder. **Context:** the column's value space is
documented OPEN (amendment 18) — unknown values fall back to server/config
resolution with a warn-once, so tier names can land without a migration;
resolution/UI is the work. **Effort:** medium. **Priority:** P3.
- [ ] **P3 — per-client token budgets.** **What:** Phase 2: per-client
response token budgets (same column pattern as surface). **Why:** a
starter-surface client with a 4K-context harness still gets full-size
payloads; budget belongs to the CLIENT, not the query. **Context:** builds
on `oauth_clients` per-client columns + the search-mode `tokenBudget` knob;
interacts with `packToBudget`/`enforceTokenBudget` (keep the frozen-verb
strictness — ENG-2). **Effort:** medium. **Priority:** P3.
- [ ] **P3 — full list-size telemetry.** **What:** first-class telemetry for
tools/list responses (per token class: count, approx bytes, trend).
**Why:** catalog size is the consumer complaint the wave started from;
today's stopgap only records the count. **Context:** the stopgap
(amendment 23) rides the existing tools/list `mcp_request_log` row as
`params.tool_count` — see the runbook's first-5-minutes SQL. A full
version would bucket bytes and surface in `gbrain search stats`-style
output. **Effort:** medium. **Priority:** P3.
- [ ] **P3 — get_job invalid_params→not_found alignment (ENG-13).** **What:**
align admin `get_job`'s unknown-id envelope with `get_agent_job`'s uniform
`not_found`. **Why:** the two job-read ops answer "no such job" with
different error codes; `get_agent_job` chose `not_found` deliberately
(anti-enumeration) and the divergence is recorded, not designed. **Context:**
ENG-13 kept `get_agent_job` at `not_found` and filed this sibling; check
callers that branch on `invalid_params` before changing. **Effort:** small.
**Priority:** P3.
## Truthful-surface wave — pre-landing review deferrals
Filed from the /ship pre-landing review of the wave branch (all classified
review-deferred, not fix-now). Grouped by component.
### MCP transport / serve-http
- [ ] **P2 — memoize the `mcp.default_surface_dcr` read on the tools/call hot
path.** **What:** a short-TTL (1530s) memo of the dual-plane
`resolveDefaultClientSurface` read for NULL-surface clients. **Why:** every
request from a NULL-surface client pays one serial config RTT today (on
network Postgres that is real latency); a 1530s memo makes the hot path
free while config flips still land within the TTL. **Context:** rescope
freshness is unaffected — the client ROW surface rides the auth JOIN in
`verifyAccessToken`, so only the config DEFAULT would be memoized
(`src/commands/serve-http.ts` resolveEffectiveSurface →
`src/mcp/surface.ts` resolveDefaultClientSurface). **Effort:** small.
**Priority:** P2.
- [ ] **P2 — extend the Postgres-host e2e with request-log row assertions.**
**What:** extend `test/e2e/serve-http-oauth.test.ts` with the honest-list
cell plus row-level twins of the new pure-function pins: a
`denied_after_list` row, a `success_with_warnings` row, and the tools/list
`params->>'tool_count'` param. **Why:** `requestLogStatusForResult` is
unit-pinned pure (test/denied-after-list.test.ts) but the INSERT wiring in
serve-http (real HTTP, real OAuth tokens, real mcp_request_log rows) only
runs on a Postgres-equipped host. **Context:** the e2e already stands up
the real OAuth server; add cells, not scaffolding. **Effort:** small.
**Priority:** P2.
- [ ] **P3 — surfaceProjectionDegraded marker for drift-shaped brains.**
**What:** a visible marker (whoami/_meta/log line) when the surface
projection is degraded because the schema is drift-shaped: v127 columns
(`oauth_clients.surface`) present but v85-era prerequisites missing.
**Why:** on the degrade ladder today an operator surface LOCK silently
widens to the server ceiling — the operator believes a pin holds when it
does not. **Context:** only reachable via restored dumps, since migrations
are ordered; cheap to detect at the existing isUndefinedColumnError seams.
**Effort:** small. **Priority:** P3.
### Minions / status
- [ ] **P3 — partial index for completed-job recency probes.** **What:**
`CREATE INDEX ... ON minion_jobs (updated_at) WHERE status='completed'` (or
fold into the wedge-index family) if `get_status_snapshot` polling becomes
frequent. **Why:** `buildWorkersSnapshot`'s `max(updated_at)` over completed
rows seq-scans today; fine at human frequency, wrong under dashboard
polling. **Context:** same family as the buildQueueDepths perf note in
`src/commands/status.ts` (partial (queue, created_at) WHERE
status='waiting' is the sibling fix there). **Effort:** small.
**Priority:** P3.
### Test infra (master-owned)
- [ ] **P1 — test/extract-atoms-chunk-embed.test.ts flakes under parallel
shards.** **What:** deflake the extract-atoms chunk-embed suite when run in
parallel shards. **Why:** it fails under shard parallelism but passes alone
— a shard-ordering trap for every future branch. **Context:** failure
signature: extraction returns status 'warn' with ALL transcripts skipped
(0 processed) → count assertions fail; env-coupling suspected — the same
withEnv class fixed in token-budget.test.ts this wave. Pre-existing on
master; owned there, not by any feature branch. **Effort:** small.
**Priority:** P1.
### Hygiene dedupe batch (single entry — take together)
- [ ] **P3 — hygiene dedupe batch from the pre-landing review.** **What:**
eight small same-shape dedupes, cheapest done as one sweep: (1) shared
`firstSentence` helper (`src/core/operations.ts` firstSentenceOf vs
`src/mcp/tool-catalog.ts` firstSentence); (2) shared empty-retrieval renderer
(`src/cli.ts` describeEmptyRetrieval vs `src/mcp/dispatch.ts`
buildEmptyRetrievalBlock); (3) generic resolveDualPlaneConfig helper for
the three hand-rolled DB>file>default reads (publish gates,
strict_params, default_surface_dcr); (4) use `isMcpSurface` at the three
literal `'verbs'|'starter'|'full'` validation sites; (5) shared `toIso`
(mcp-usage.ts vs siblings); (6) export the MCP_USAGE window bounds
([1, 3650]) from mcp-usage.ts and consume in parseAuthClientsArgs +
derive-starter-ops instead of re-typing; (7) reuse buildQueueDepths
(status.ts) in doctor's waitingByQueue + the supervisor probe instead of
three copies of the same GROUP BY; (8) compose rescopeClient's
optional-column branch matrix instead of enumerating it. **Why:** each is
a copy that can drift independently; none is worth its own entry.
**Context:** all two-way doors, no behavior change intended — land with
the existing pins green. **Effort:** medium (as a batch). **Priority:** P3.
### Adversarial-review deferrals (cross-model, ship-stage)
Filed from the /ship adversarial review (Codex + Claude synthesis). The twelve
fix-now findings landed on the branch; these four are the review-deferred tail.
- [ ] **P2 — request_tools persist: fold the old-surface read into the atomic
UPDATE.** **What:** replace the SELECT → UPDATE → audit-write triple with
one `UPDATE ... RETURNING (SELECT surface FROM oauth_clients WHERE ...)`
(or capture old via `RETURNING` on a CTE) so the audit row's `old` value
can never be a stale read from before a concurrent change. **Why:** today
a rescope racing the persist can make the audit trail record a wrong
`old→new` transition — the trail answers "why did the surface change" and
must not lie under concurrency. **Context:**
`src/core/operations.ts` request_tools persist branch +
`src/core/surface-audit.ts`; both engines (CTE-in-UPDATE parity check).
**Effort:** small. **Priority:** P2.
- [ ] **P3 — persist rate-limit durability across restarts/processes.**
**What:** decide whether the request_tools persist limiter (in-memory
token bucket, ~5/hr/client) needs DB-backed durability. A server restart
refills every bucket; a multi-process fleet multiplies the budget by
process count. **Why:** today the cap is advisory under restart churn —
fine for the abuse class it targets (runaway clients), wrong if it ever
guards something stronger. **Context:** `src/mcp/rate-limit.ts` +
`requestToolsPersistLimiter`; the surface_change audit rows already give
a DB-side count to enforce against if needed. **Effort:** medium.
**Priority:** P3.
- [ ] **P3 — cancel (not just abandon) timed-out submit-time queue probes.**
**What:** the WP5 wedge/pause probes time-bound via Promise.race, but the
losing query keeps running on the pool after the race resolves. Wire
AbortSignal / statement_timeout so a slow probe releases its slot. **Why:**
under pool exhaustion (the exact regime the probes exist to detect) an
abandoned probe query holds a pooler slot and makes the exhaustion worse.
**Context:** `src/core/minion/supervisor.ts` queryWedgeSignals callers in
`src/core/operations.ts` submit paths. **Effort:** small. **Priority:** P3.
- [ ] **P3 — document the status --json snapshot union under schema_version.**
**What:** a short protocol note (docs/progress-events.md sibling) pinning
the `get_status_snapshot` v2 shape as a discriminated union on
`schema_version` (v1: no queue/workers keys; v2: sections present but
per-section fail-soft `{error: 'unavailable'}`), plus a compat table for
thin-client consumers. **Why:** external `--json` consumers can't rely on
reading the TypeScript; the fail-soft section shapes are non-obvious.
**Context:** `src/core/operations.ts` get_status_snapshot,
`src/commands/status.ts` thin-client sections. **Effort:** small.
**Priority:** P3.
## Onboarding DX follow-ups (filed v0.45.9.0)
- [ ] **Retire the `config set embedding_model` dead-end across ALL surfaces.** v0.45.9.0 fixed the keyless-init notice to point at `gbrain init --force --pglite --embedding-model <id>`, but `src/core/embed-preflight.ts` (lines ~73/83/90/115) and `src/core/embedding-dim-check.ts:78` still advertise `gbrain config set embedding_model <...>`, which `src/commands/config.ts:142` hard-refuses as a schema-sizing no-op. Same dead-end class, different surfaces. Sweep them to the re-init recipe. Priority: P2.
- [ ] **`gbrain init --supabase` migrate-model dead-end doc.** The Postgres branch of config.ts points at `docs/embedding-migrations.md`; confirm that doc exists and describes a working switch, or write it. Priority: P3.
- [ ] **DX harness binary cache keyed on nothing.** `scripts/dx-explore.ts` reuses `.context/dx-runs/bin/gbrain` unless `--rebuild` is passed, so a second run after code changes can produce transcripts from a stale binary. Key the cache by a source hash (or rebuild when any `src/` file is newer). Dev instrument only. Priority: P3.
- [ ] **`verify` has no MCP-registration check.** v0.45.9.0 made `bootstrap status` report the wire phase `partial` when only hooks landed (host CLI missing), but `bootstrap verify` still exits 0 in that state. Add an MCP-registration probe to verify so the "done when verify exits 0" contract also covers MCP. Priority: P2.
- [ ] **`hasExpansionKey` misses config-plane keys + init-before-key sequencing.** The mode picker reads `process.env` only; a key routed to the 0600 config by the interview (which runs AFTER init) never influences the auto-selected search mode, and the picker never re-fires. Resolve keys through the capability/gateway fold and consider re-running the recommendation when a key is first configured. Priority: P3.
- [ ] **`findEnvKeyTypos` KEY_SHAPE misses no-underscore typos.** `OPENAI_APIKEY` (no `_` before `KEY`) escapes the near-miss net, so that typo class now completes keyless silently instead of failing loud. Widen the regex. Priority: P3.
- [ ] **`init-nudge` stale "4 checks" comment + 6-probe accounting.** The header still says "4 onboard checks" but six probes now run; the partial-checks message counts the page-count probe. Cosmetic. Priority: P3.
- [ ] **FIRST LIGHT (the real first-magical-moment feature).** The v0.45.9.0 tour rewrite is the ship-now slice; the full seed-phase → compendium → scout design is PR-A (seed phase + Mirror + baton) / PR-B (compendium + scout) with one-way-door decisions (new bootstrap phase, consent key, `skills/first-light/`, a one-time Gate-3 narration exemption). Priority: P2.
## Ambient recall follow-ups (filed v0.45.7.0, issue #1)
Deferred from the ambient-recall wave (`context_pack` + `delta` frozen verbs +
@@ -88,7 +297,11 @@ Deferred from the BrainBench wave (eng-reviewed; plan + GSTACK REVIEW REPORT at
- [ ] **Hermetic-ize the 7 env-sensitive LLM-availability tests.** `test/think-gateway-adapter.test.ts`, `test/conversation-parser/llm-base.test.ts`/`llm-fallback.test.ts`, `test/doctor-ze-checks.test.ts` assert behavior "when ANTHROPIC_API_KEY is unset" by reading the live process env — they fail on any dev shell that exports provider keys (verified failing on clean master in such a shell; green in keyless CI). Stub/save-restore the env per test so local runs match CI. Priority: P2.
## #2416 follow-ups (query-steering wave)
- [ ] **P2 — MCP-envelope `hint` field for concept-shaped `search` calls.**
- [x] **P2 — MCP-envelope `hint` field for concept-shaped `search` calls.**
DONE (Truthful Surface Wave, E1): the hint rides `_meta.retrieval.hint` on the
`search` op (the sibling-metadata-channel option this entry proposed) plus the
model-visible second content block on empty results. See
`docs/protocol/MCP_META_CHANNELS.md`.
**What:** surface the concept→query nudge to remote/MCP agent callers, not
just the CLI. **Why:** MCP agents are the primary misrouting class the
#2416 issue describes; the shipped CLI stderr nudge covers the caller class
@@ -587,7 +800,10 @@ events at the IPC delivery point and dedupes via the transcript's
0700 dir) and (b) a secret-file home for `turn_context` auth (same hash-keyed run dir).
The cathedral-3 branch prototyped (a) as `resolveSocketPathForConfig` (see branch
history at commit 2350294c) before the convergence dropped it pending the secret
design. **Trigger:** a Postgres-brain user asking why hooks stay silent. **Start:**
design. **Trigger:** a Postgres-brain user asking why hooks stay silent — and as of
#4043, every `gbrain bootstrap harness` install on a Postgres brain: harness mode
pre-wires all five hooks and states the degradation plainly, so this listener is what
lights them up. **Start:**
`src/core/context/resolve-ipc.ts` socket-path helpers + `src/mcp/server.ts` listener gate
+ `src/commands/hook.ts:no_pglite_path` branch.
- [ ] **P3 — thin-client remote push route.** Thin-client installs (remote_mcp) have no
@@ -3829,28 +4045,99 @@ After the sweep, both should be fixable and renameable back to plain `*.test.ts`
## claw-test E2E (v0.22.16 follow-ups)
### Hermes runner — `src/core/claw-test/runners/hermes.ts`
**Priority:** P2
**What:** Add a Hermes implementation of the `AgentRunner` interface. v1 ships only OpenClaw; v1.1 lands hermes once we have real friction reports from openclaw to validate the contract against.
**Why:** Cross-agent diff (`gbrain friction diff --base openclaw --compare hermes`) is the highest-leverage next signal. Friction unique to one agent vs common-to-both separates "agent contract bug" from "gbrain bug" automatically.
**Effort:** S (CC ~30m). Depends on: v1 openclaw runner producing real friction reports first.
### ~~Hermes runner — `src/core/claw-test/runners/hermes.ts`~~ DONE (hermes-harness wave)
Shipped: `HermesRunner` (`hermes -z <brief>`, `$HERMES_BIN` > `which hermes`,
`HERMES_HOME` env-allowlist delta) + the full hermes install door
(`test/e2e/install-real-hermes.serial.test.ts`, opt-in-gated) + the label-gated
`hermes-door` CI job in heavy-tests.yml. The cross-agent
`gbrain friction diff --base openclaw --compare hermes` payoff shipped in the
same wave (below). Observed-CLI pins live in `docs/mcp/HERMES-CLI-PIN.md` and
`docs/mcp/HERMES.md`.
---
### Friction analytics suite — `diff` / `trend` / `migration-stub`
### Friction analytics suite — `trend` / `migration-stub` (diff SHIPPED)
**Priority:** P2
**What:** Three new `gbrain friction` subcommands deferred from v1:
- `gbrain friction diff --base <run-or-agent> --compare <run-or-agent>` (cross-agent comparison; ~80 LOC)
**What:** Two remaining `gbrain friction` subcommands deferred from v1
(`diff` shipped in the hermes-harness wave — see `src/commands/friction.ts`):
- `gbrain friction trend [--since <version-or-date>] [--phase <name>]` (time-series across runs; ~60 LOC)
- `gbrain friction migration-stub [--threshold N]` (clusters friction by phase + tokens, emits `skills/migrations/v[N+1].md` stub; ~150 LOC)
**Why:** Turns point-in-time reports into a slope. Pairs with the v1.1 public scoreboard.
**Effort:** M (CC ~2h total).
**Effort:** M (CC ~1.5h total).
---
### Promote hermes-door soft probes to hard assertions + build the REAL cron test
**Priority:** P2
**What:** Two follow-ups now that the hermes CLI surface is pinned (v0.20.0,
`docs/mcp/HERMES-CLI-PIN.md`): (1) promote the door's logged-evidence probes
(`hermes mcp list` output shape; session-artifact tool-call traces under
`<home>/.hermes/`) to hard assertions once a couple of CI runs confirm their
stability across hermes releases; (2) build the real cron pairing test — the
surface is fully non-interactive (`hermes cron create [--name N] [--no-agent]
[--script PATH] <schedule> [prompt]` + `hermes cron tick` runs due jobs once
and exits) — create a job that runs `gbrain sync --json`, tick, and assert the
sync actually executed against the run's brain. (A self-skipping probe was
deliberately CUT in review: a test that cannot fail is not coverage.)
**Why:** INSTALL_FOR_AGENTS.md's recurring-jobs step has zero coverage; the
evidence sweep is the promotion signal the door already logs.
**Effort:** S-M (CC ~45m). Depends on: first labeled hermes-door CI runs.
---
### Wire the orphaned `voice-agent-install` ScenarioKind
**Priority:** P2
**What:** `test/fixtures/claw-test-scenarios/voice-agent-install/` carries the
richest install-assertion template in the repo (60-line expected.json:
filesystem manifest, `.gbrain-source.json` sha256s, resolver rows, PII
blocklist, health probe, tiered soft-fail) but `scenario.json` declares
`kind: "voice-agent-install"`, which `ScenarioKind` rejects — the fixture
cannot load. Extend `ScenarioKind` + `loadScenario` + a `postInstallHook`
implementation so the scenario runs.
**Why:** Integrations-recipe install coverage (the `gbrain integrations
install` path) has a fully-designed scenario sitting dead.
**Effort:** M (CC ~1h). Integrations-lane work, deliberately kept out of the
hermes-harness wave.
---
### Cold-install container test — fill the `tests/docker/bootstrap-e2e.sh` placeholder
**Priority:** P3
**What:** heavy-tests.yml carries a gated no-op step for
`tests/docker/bootstrap-e2e.sh` (networkless cold-machine container install of
gbrain itself: global install, PATH discovery, migrations). The file doesn't
exist. Write it.
**Why:** The agent-platform door tests (claude/codex/hermes) all deliberately
run gbrain from the dev tree / compiled binary — none of them proves gbrain's
own cold install. That gap was re-flagged in the hermes-harness wave's outside
review and scoped OUT of that wave on purpose.
**Effort:** M (CC ~1-2h, docker).
---
### BrainBench hermes adapter
**Priority:** P3
**What:** ~50-100 lines in `src/eval/brainbench/adapters/hermes.ts` + an
`ALL_HARNESSES` entry + baseline cells in `evals/brainbench/baselines/main.json`.
**Why:** Cross-harness memory-conformance coverage for the third platform.
Eval seam (memory conformance), NOT install — kept out of the install wave on
purpose; needs baseline-governance care per the BrainBench gate rules.
**Effort:** S-M (CC ~1h + baseline runs).
---
@@ -3870,7 +4157,7 @@ After the sweep, both should be fixable and renameable back to plain `*.test.ts`
### Real v0.18 SQL dump for upgrade scenario
**Priority:** P2
**What:** The `upgrade-from-v0.18` scenario ships scaffolded — `seed/dump.sql` is missing. The harness gracefully no-ops the seed phase when absent, so the scenario currently behaves like fresh-install. v1.1: generate a real v0.18-shape PGLite dump per the procedure documented in `test/fixtures/claw-test-scenarios/upgrade-from-v0.18/seed/README.md`.
**What:** The `upgrade-from-v0.18` scenario ships scaffolded — `seed/dump.sql` is missing. Both scripted and live runs now FAIL LOUDLY on the missing dump (a silent skip used to init a current database and false-green the "upgrade"), so the shipped scenario is unrunnable until the dump lands. Generate a real v0.18-shape PGLite dump per the procedure documented in `test/fixtures/claw-test-scenarios/upgrade-from-v0.18/seed/README.md`.
**Why:** Without a real seed, the scenario doesn't actually exercise the migration chain forward-walk. That's the whole point of the upgrade scenario — proves issue #239/#243/#266/#357 class regressions stay fixed.
@@ -5124,8 +5411,135 @@ respective shapes. Small, mechanical; pinned by `test/init-embed-check.test.ts`
**Depends on:** nothing.
## Harness-mode follow-ups (#4043, filed at build time)
- [ ] **P2 — serve-side port/pid record for discovery.** `gbrain bootstrap harness`
and its `--status` probe `/health` at 127.0.0.1:3131 (or an explicit `--url`/`--port`);
a serve on a non-default port is invisible without flags. Write a record (port, pid,
started_at) from `runServeHttp`'s `app.listen` callback into `~/.gbrain/run/`,
mtime-as-heartbeat like `src/core/autopilot-paths.ts` — the stale-record semantics
(crashed serve, multi-serve boxes) are why this deferred; a wrong record misdirecting
probes is worse than no record. **Trigger:** a harness box running serve on a custom
port asking why discovery misses it. **Start:** `src/commands/serve-http.ts` listen
callback + `src/core/bootstrap/harness.ts` url resolution.
- [ ] **P3 — legacy HTTP transport scope asymmetry.** `src/mcp/http-transport.ts` is
test-only (no production caller; `serve --http` uses serve-http.ts) and hardcodes
`scopes: []` with no per-op scope gate — if it is ever revived, a scoped legacy token
is fully UNSCOPED there. Mirror the `scopes TEXT[]` honor + `hasScope` dispatch gate
before any revival. **Trigger:** any production caller of `startHttpTransport`.
- [ ] **P3 — partial unique index on active `access_tokens.name`.** Names are not
unique; `auth revoke <name>` clears every active row and the 23505 handler in
`auth create` is dead code for name collisions. Harness mode sidesteps this with
revoke-by-id + receipt-carried ids, but a
`CREATE UNIQUE INDEX ... ON access_tokens (name) WHERE revoked_at IS NULL` would
make names honest for humans too. Needs a dedup pass first on brains that already
carry twins. **Start:** `src/core/migrate.ts` (CONCURRENTLY + `transaction: false`).
- [ ] **P2 — codex hook lane.** codex-cli 0.147.0 ships a real hook system (hooks.json;
PreToolUse…SessionEnd — recorded on `TARGETS['codex-2026-08']` in
`src/core/bootstrap/host-specs.ts`), falsifying the old "codex has no hooks" premise.
Wiring SessionEnd transcript capture (+ SessionStart context) would give codex
sessions the same memory loop Claude Code gets, and supersedes the FF2 notify-sweeper
idea. Needs its own dated spec-target verification (payload shapes, deny-unknown-fields
config) + e2e before any writer lands. **Trigger:** first user asking why codex
sessions don't persist; **Start:** `host-specs.ts` TARGETS + a codex sibling of
`writeClaudeHooksAt`.
- [ ] **P3 — PGLite admin-lane scoped minting.** `gbrain bootstrap harness` refuses to
mint under a live PGLite serve (single-writer) and points at pre-mint + `--token`.
Auto-driving `POST /admin/login` + `POST /admin/api/api-keys` (when
GBRAIN_ADMIN_BOOTSTRAP_TOKEN is present) would erase that friction — BLOCKED ON
extending that admin route to carry a scopes/permissions payload (today it inserts
only id/name/token_hash, so it can only mint full-access tokens, defeating the
harness lane's least-privilege default). **Start:** `src/commands/serve-http.ts`
api-keys route + `src/core/bootstrap/harness.ts` mint seam.
- [ ] **P3 — OpenClaw plugin setup hook (self-demoted from the #4043 wave).** The
issue's closing ask is "frameworks call `gbrain bootstrap harness` at setup time".
The in-repo `openclaw.plugin.json` cannot express it: OpenClaw installs plugins with
lifecycle scripts disabled (`--ignore-scripts`) and the manifest schema has no
setup/command field (verified against the OpenClaw plugin docs, 2026-08-12). When the
plugin API grows a setup surface, add `gbrain bootstrap harness --yes` AND remove the
manifest's static stdio `mcpServers.gbrain` entry in the same commit (one owner per
server name). **Trigger:** OpenClaw plugin-API setup/command support shipping.
- [ ] **P3 — harness federated-drift visibility.** The harness token's
`permissions.source_id` federation array is a mint-time snapshot of the
`federated=true` sources; sources added later are invisible to wired sessions until
a re-run rotates the token. `--status` could diff the snapshot against the live
config and suggest a re-run — needs either an engine open (breaks status's
engine-free posture under a live PGLite serve) or a sources probe over MCP with the
recovered token. **Start:** `src/core/bootstrap/harness.ts:statusHarness`.
- [ ] **P3 — harness smoke: add BRAIN-IDENTITY comparison on top of the canary
(ship-review residual).** The ship-review batch landed the two cheap layers: an
apply-time CANARY (a random same-format bearer must fail auth before the real smoke —
an impostor cannot tell the canary from the real token, so it is caught whichever way
it answers) and immediate revocation of the fresh mint on any failed smoke. The
remaining hardening is comparing the smoke's returned identity against the local
brain's (the default mint path already opens the engine and could capture it);
registrar mode (`--token` + remote url) has no engine and would state the weaker
guarantee honestly. **Start:** `src/core/bootstrap/harness.ts` steps 5+8.
- [ ] **P3 — harness orphan-mint reconciliation (red-team finding).** A hard crash in
the window between the mint INSERT committing and the `receipt.token.id` save leaves
an ACTIVE token no receipt records — `--remove` cannot revoke it and doctor never
flags it. On apply, when the prior receipt has `minted: true` but no id, list active
`access_tokens` rows matching `token.name` created after `receipt.created_at` and
fold them into `previous_ids` (or surface them loudly). **Start:**
`src/core/bootstrap/harness.ts` step 5 + `src/core/token-mint.ts`.
- [ ] **P3 — bootstrap lock.ts error-path polish (plan micro-item, deferred at ship).**
Non-EEXIST mkdir errors (EACCES/EROFS) misreport as BOOTSTRAP_IN_PROGRESS, and the
missing-dir message says "workspace directory" even when the lock target is the
gbrain HOME (harness lane) or a host config dir. Add an accurate message path.
**Start:** `src/core/bootstrap/lock.ts:acquireBootstrapLock`.
- [ ] **P3 — dedupe `auth create` against `mintLegacyToken`.** `src/commands/auth.ts`
create() re-implements the INSERT + `{a,b}` text[]-literal trick that token-mint.ts
owns (the extraction note says so); routing create() through `mintLegacyToken` (the
engine is in scope inside `withConfiguredSql`) would leave one canonical mint. Same
for the doctor's inline `/health` probe vs `probeServeHealth`, which also wants an
injectable fetch seam so `bootstrap_harness_health` tests stop making real TEST-NET
calls (3s each). **Start:** `src/commands/auth.ts:create`, `src/commands/doctor.ts`
bootstrap_harness_health.
## Agent-bootstrap wave follow-ups (filed at build time)
- [ ] **P2 — repoPhaseComplete is single-workspace (one global receipt).** The
no-daemon push gate binds to the one `receipt.repo_url`, so with two bootstrap
workspaces sharing a gbrain home, workspace B's `bootstrap repo` overwrites the
receipt and permanently leaves A's per-turn/session-end pushes at
`push_deferred_repo_pending`. Fails CLOSED (defers, never mis-pushes) and
matches the v1 single-workspace contract, but the per-turn push made it more
visible. Fix = per-root repo binding (a receipt map or a per-root marker).
Surfaced by both v0.45.9.0 adversarial reviewers.
- [ ] **P2 — visibility ladder subprocess/body bounds.** `runWithTimeout`
(`src/core/repo-visibility.ts`) races the `gh`/`git` probe against a timer but
doesn't kill the raced child, and the anon-probe `res.text()` buffers the whole
(operator-configured-origin) body before slicing. Bounded in practice by the
detached push child's lifetime, but a proper fix kills the raced process and
caps the body read. Filed from the v0.45.9.0 Codex adversarial pass.
- [ ] **P3 — `config set` for the file-plane hook-lane keys is engine-bound.**
`runConfig` dispatches through the engine path, so `gbrain config set
push.allow_unverified_remote true` can fail while a live PGLite serve holds the
writer lock — the documented recovery command, unavailable exactly when needed.
The env-var form (`GBRAIN_ALLOW_UNVERIFIED_REMOTE=1`) is the cloud path and needs
no engine, so this is convenience-only; fix = route these two keys through the
no-engine CLI dispatch. Filed from the v0.45.9.0 Codex adversarial pass.
- [ ] **P3 — plugin-based hook distribution for Claude Code.** Ship gbrain's
hooks as a Claude Code plugin (`hooks/hooks.json` + `.claude-plugin/plugin.json`
manifest, installed via the plugin marketplace flow) instead of two settings
files. Plugins merge hooks first-class across scopes and update centrally —
it would REPLACE both current carriers (repo-committed `.claude/settings.json`
for cloud installs + gitignored `settings.local.json` for local), so it must
migrate, not join; a third simultaneous carrier would double-fire events.
Cons: needs marketplace repo hosting; enterprise `allowManagedHooksOnly`
policies can block plugin hooks entirely. Start at
`src/core/bootstrap/hooks.ts` (both writers + the dedupe rule live there).
Filed from the cloud-DX eng review (v0.46.x wave).
- [ ] **P3 — watch Claude Code Channels as the push path for
volunteer_context/signals.** Channels (research preview) push external events
into a LIVE session — the native version of gbrain's push-context lane
(`docs/guides/push-context.md`). Not actionable today: delivery requires an
always-on session plus an Anthropic-allowlisted channel plugin. Revisit when
channel-plugin distribution opens; the win is replacing per-turn pull with
event push for signals/reflex windows. Filed from the cloud-DX eng review.
- [ ] **P1 — enforce op scope/localOnly on the stdio MCP dispatch when no auth
context is present, and consider a narrower default surface for pull-mode
harness registrations.** HTTP dispatch enforces `scope`/`localOnly` before
+1 -1
View File
@@ -1 +1 @@
0.45.7.0
0.45.14.0
+1 -1
View File
@@ -71,7 +71,7 @@ claude mcp add gbrain -- gbrain serve --surface verbs # Claude Code
codex mcp add gbrain -- gbrain serve --surface verbs # Codex
```
The agent spawns `gbrain serve` as a stdio subprocess against your local brain. `--surface verbs` gives the agent the five-verb memory protocol (`recall`, `remember`, `entity`, `synthesize`, `forget` — [MEMORY_VERBS v1](protocol/MEMORY_VERBS_v1.md)) instead of the full tool catalog; drop the flag (default `full`) for every operation. Full walkthrough (both this local path and connecting to a remote brain), plus the brain-first protocol to paste into `CLAUDE.md` / `AGENTS.md`: **[Give your coding agent a memory](tutorials/connect-coding-agent.md)**.
The agent spawns `gbrain serve` as a stdio subprocess against your local brain. `--surface verbs` gives the agent the seven-verb memory protocol (`recall`, `remember`, `entity`, `synthesize`, `forget`, `context_pack`, `delta` — [MEMORY_VERBS v1](protocol/MEMORY_VERBS_v1.md)) instead of the full tool catalog; `--surface starter` adds the daily-driver set on top of the verbs (~26 ops total); drop the flag (default `full`) for every operation. Full walkthrough (both this local path and connecting to a remote brain), plus the brain-first protocol to paste into `CLAUDE.md` / `AGENTS.md`: **[Give your coding agent a memory](tutorials/connect-coding-agent.md)**.
## 3. MCP server (any MCP client)
+5 -1
View File
@@ -176,6 +176,7 @@ Unit tests and what they cover:
- `test/watch-command.test.ts``gbrain watch` push transport (#2095): streaming loop, rolling window, session dedupe, `--json` JSONL shape, `channel: 'watch'` event logging, clean EOF return. Hermetic PGLite + injected line/write deps (no subprocess, no real stdin).
- `test/watch-sigint.serial.test.ts``gbrain watch` SIGINT lifecycle against a real spawned CLI subprocess with a tmpdir brain. SERIAL: parallel unit shards flake on concurrent subprocess spawns (same rationale as `apply-migrations-pglite-spawn.serial.test.ts`).
- `test/autopilot-launchd-lifecycle.serial.test.ts` — autopilot lifecycle behavior, not generated-string assertions: the full install → self-disable → status → reinstall → uninstall arc with `launchctl` replaced by an argv recorder and the generated wrapper executed by a REAL bash against a genuinely deleted repo (every platform), plus a darwin-only fail-SKIP describe against the real launchd under a per-run unique label (`GBRAIN_AUTOPILOT_LABEL`) so it can never collide with — or tear down — a real install on the host. Serial: spawns subprocesses and pins HOME/GBRAIN_HOME for the whole file.
- `test/autopilot-fanout.test.ts` — Autopilot fan-out and #4046 policy regression: targeted idempotency keys reopen per dispatch interval while stable doctor/remediate keys remain unchanged; the 60-minute full-cycle floor wins with a remaining small plan, and an all-fresh restart check advances the process-local clock without masking failed stale-source submissions.
- `test/agent-scheduler-contract.serial.test.ts` — the documented external agent-scheduler shell chain (`gbrain sync --repo X && gbrain embed --stale`, live-sync.md / INSTALL_FOR_AGENTS.md Step 7) driven end-to-end through a real `/bin/sh` against a keyless PGLite brain: the `&&` short-circuit IS the contract (argv arrays can't exercise it), the keyless bare stale embed exits 0, and the pull-failure case that must break the chain does. Anti-vacuity: the fixture commits a real page and every read-back asserts pages >= 1. Serial: real spawned CLI + tmpdir HOME.
- `test/cli-format-volunteer.test.ts``formatResult`'s `volunteer_context` human rendering: pointer lines with confidence/arm/rationale, the empty-result message, the approximate stats summary.
- `test/config.test.ts` — config redaction.
@@ -281,7 +282,9 @@ E2E tests live in `test/e2e/` and run against real Postgres+pgvector (require `D
- `test/e2e/pglite-cli-exit.serial.test.ts` — real spawned-CLI exit behavior on PGLite (in-memory, no `DATABASE_URL`): read commands (`search`/`get`/`query`) exit 0 promptly; CLI_ONLY `capture` exits clean and frees the single-writer lock; the `#2084` describes pin every swept disconnect site — a failed op exits 1 with the error on stderr, and the dashboard, read-only-timeout, doctor, and `dream --dry-run` paths all exit with no force-exit banner.
- `test/e2e/pgbouncer-teardown.test.ts` — PgBouncer TRANSACTION-mode teardown (#2084 / the #1972#2015#2084 class). Pins the bug CLASS, not timings: a CLI op against a txn-mode pooled URL exits 0 with intact stdout and does NOT ride the 10s hard-deadline backstop (the `engine.disconnect() did not return` banner is the smoking gun — pre-#2084 it printed on 100% of query-shaped ops). Gated by `GBRAIN_PGBOUNCER_URL` + `GBRAIN_PGBOUNCER_DIRECT_URL` (NOT `DATABASE_URL`) — set automatically by `bun run ci:local`'s `pgbouncer` compose service; skips gracefully elsewhere. Uses a DEDICATED `gbrain_pgbouncer` database so it never races the `gbrain_test` TRUNCATE fixtures.
- `test/e2e/volunteer-context-postgres.test.ts``volunteer_context` on REAL Postgres (#2095; engine parity beyond the hermetic PGLite unit suite): resolution arms through the actual op handler, the fire-and-forget volunteer-event sink landing rows, the stats join, and the RLS pin that `context_volunteer_events` has ROW LEVEL SECURITY enabled (keeps the v35 auto-RLS event trigger honest for migration-created tables). `DATABASE_URL`-gated.
- `test/e2e/openclaw-reference-compat.test.ts``check-resolvable` + `skillpack install` against a minimal AGENTS.md workspace fixture (`test/fixtures/openclaw-reference-minimal/`), regression guard for the OpenClaw deployment shape.
- `test/e2e/openclaw-reference-compat.test.ts``check-resolvable` + skillpack install-model against a minimal AGENTS.md workspace fixture (`test/fixtures/openclaw-reference-minimal/`), regression guard for the OpenClaw deployment shape.
- `test/e2e/workspace-generic-compat.test.ts` — always-on (PGLite, no binary): pins the INSTALL_FOR_AGENTS.md "any repo with a workspace" contract against `test/fixtures/generic-agents-workspace/` (Hermes is the motivating consumer): `cwd_walk_up` detection, the `GBRAIN_SKILLS_DIR` override, `check-resolvable` on a root AGENTS.md, and scaffold additivity + refuse-overwrite. The real Hermes-behavior proof is the door suite below.
- `test/e2e/install-real-hermes.serial.test.ts` — the hermes "door": real `hermes` binary + real `hermes mcp add` handshake (full-catalog tool discovery; the count tracks the op catalog, so the test asserts discovery happened, not a number) + a paid `hermes -z` recall turn against a seeded brain. Triple-gated: `GBRAIN_REAL_HERMES_E2E=1` (explicit opt-in — run-e2e.sh scrubs GBRAIN_*, so it can never fire under `bun run test:e2e`) + resolvable binary + non-empty ANTHROPIC key (anthropic-pinned on purpose: a second provider key flips hermes provider-auto into a mis-routed 401). Hermetic HOME + HERMES_HOME with a tripwire on the operator's real config; evidence copies to `GBRAIN_E2E_EVIDENCE_DIR` for CI upload. Venue: heavy-tests.yml (`real-agent-e2e` + `hermes-door` jobs).
- `test/e2e/search-swamp.test.ts` — reproduces the source-swamp case. Seeds a curated `originals/talks/article-outline-fat-code` page against two `<fork>/chat/` pages stuffed with the same multi-word phrase. Asserts the article wins keyword AND vector ranking, that `detail=high` lets the chat swamp re-surface, and that `source_id` passes through the two-stage CTE intact. PGLite in-memory.
- `test/e2e/search-exclude.test.ts``test/` + `archive/` pages hidden by default, `include_slug_prefixes` opts back in, caller-supplied `exclude_slug_prefixes` adds to defaults. Both keyword and vector search paths.
- `test/e2e/engine-parity.test.ts` — Postgres ↔ PGLite top-result and result-set parity for `searchKeyword` + `searchVector` (Postgres ranks pages then picks best chunk while PGLite returns chunks directly, so the source-boost behavior needs parity coverage). Skips without `DATABASE_URL`.
@@ -296,6 +299,7 @@ E2E tests live in `test/e2e/` and run against real Postgres+pgvector (require `D
- `test/e2e/think-source-isolation-pglite.test.ts` — PGLite in-memory suite pinning the `think` gather stage's source scope: seeds three sources with cross-source links and embedded takes, then asserts `runGather` under a federated `sourceIds` grant (and under a scalar `sourceId`) keeps every stream — hybrid retrieval, takes keyword + vector (`searchTakes`/`searchTakesVector`), and the `traversePaths` graph walk — inside the grant while still reaching authorized neighboring sources. No `DATABASE_URL` needed.
- `test/e2e/skill-brain-first.test.ts` — doctor reports `skill_brain_first` check with structured issues; `--fix --dry-run` previews insertion without writing; `--fix` applies the canonical Convention callout idempotently; `brain_first: exempt` frontmatter resolves the warn; `brain_first_typo` surfaces a paste-ready hint; audit JSONL records `detected` / `resolved` / `fixed` transitions; stable brain emits 0 audit lines/run.
- Tier 2 (`test/e2e/skills.test.ts`) requires OpenClaw + API keys, runs nightly in CI.
- `test/e2e/claw-test.test.ts` also covers live mode token-free via shim agents (`OPENCLAW_BIN=<sh script>`): the success-oracle break path (a do-nothing agent now FAILS), the E0 child-friction merge surviving tempdir cleanup, and the upgrade staging + schema-version-probe regression.
- If `.env.testing` doesn't exist in this directory, check sibling worktrees: `find ../ -maxdepth 2 -name .env.testing -print -quit` and copy it here if found.
- **Run E2E tests without asking permission.** When you want to verify behavior, there's a relevant E2E test, or you're shipping anything covered by an E2E suite — spin up the test DB, run the tests, tear down. Don't ask, don't propose it, don't defer. The lifecycle is short (~2-30s startup, sub-minute tests, instant teardown) and the gate value is high. Skipping with "DATABASE_URL unset" is silent regression, not caution.
+222
View File
@@ -0,0 +1,222 @@
# MCP tool catalog
<!-- GENERATED FILE — do not edit by hand. -->
<!-- Regenerate: bun run scripts/generate-tool-catalog.ts -->
<!-- Freshness-guarded by scripts/check-tool-catalog-fresh.sh (bun run verify). -->
Every non-localOnly operation on the MCP surface: 104 tools across 22 areas. **Starter** marks membership in the ~26-op `starter` surface (`src/mcp/surface.ts`); **Gate** names the config key that must be true before remote callers see/call the op (`gbrain config set <key> true`). What a given token actually sees is further filtered per request by scope, bound-client fence, publish gates, and the per-client surface — see `docs/operations/mcp-surface-runbook.md`. Area names are non-contractual groupings.
## admin
| Tool | Description | Scope | Starter | Gate |
|---|---|---|---|---|
| `get_health` | Brain health dashboard (embed coverage, stale pages, orphans) | admin | | |
| `get_stats` | Brain statistics (page count, chunk count, etc.) | admin | | |
| `get_status_snapshot` | Snapshot for `gbrain status` thin-client mode: sync freshness + last cycle + queue depths + worker liveness. | admin | | |
| `run_doctor` | Run brain health checks and return a structured DoctorReport (thin-client doctor surface). | admin | | |
| `run_onboard` | Probe brain health + optionally submit onboard remediations. | admin | | |
| `run_skillopt` | Run SkillOpt against a single skill. | admin | | |
## advisor
| Tool | Description | Scope | Starter | Gate |
|---|---|---|---|---|
| `advisor` | Ranked, read-only "what to do next" for this brain: version drift, pending migrations, schema-pack issues, stalled jobs, usage-shape gaps, and setup smells. | read | | `mcp.publish_advisor` |
## chronicle
| Tool | Description | Scope | Starter | Gate |
|---|---|---|---|---|
| `chronicle_day` | Life Chronicle: events + timeline entries on a given day (or its ISO week when week=true), ordered chronologically; each row backlinks to its depth page. | read | | |
| `chronicle_last_seen` | Life Chronicle: when an entity was last seen — its own timeline rows OR an event's `who`. | read | | |
| `chronicle_on_this_day` | Life Chronicle: events from the same calendar day in PRIOR years ("on this day"). | read | | |
| `chronicle_since` | Life Chronicle: events + timeline entries on or after a date, optionally filtered by event kind. | read | | |
| `volunteer_chronicle` | Life Chronicle agent-orientation: the recent timeline (last N days) + the current validity-resolved ontology for the named entities, in one zero-LLM payload, so an agent orients before acting. | read | | |
## code
| Tool | Description | Scope | Starter | Gate |
|---|---|---|---|---|
| `code_blast` | BEFORE editing any function, run code_blast with the symbol name to surface every transitive caller grouped by depth (direct → 2-hop → 3-hop). | read | | |
| `code_callees` | When tracing how a function flows to its dependencies (DB calls, HTTP calls, file I/O), run code_callees from the entry point. | read | | |
| `code_callers` | BEFORE editing any function, run code_callers with the symbol name to find every caller (the people who'd be affected by your change). | read | | |
| `code_def` | Where is this symbol defined? | read | | |
| `code_flow` | When tracing how a request flows through the codebase from entry point to side effect (DB write, HTTP call, file I/O), run code_flow from the entry point. | read | | |
| `code_refs` | Find every reference to a symbol across the codebase (every file, every line). | read | | |
## discovery
| Tool | Description | Scope | Starter | Gate |
|---|---|---|---|---|
| `request_tools` | Discover this brain's tool catalog and optionally unlock a wider tool surface for your client. | read | yes | |
## entities
| Tool | Description | Scope | Starter | Gate |
|---|---|---|---|---|
| `extract_entities` | Extract entity names (people, companies) from text and create/update their brain stub pages. | write | | |
| `extraction_pending` | List unverified auto-extracted entity stubs awaiting owner review (the quarantine lane from extract_entities). | read | | |
## identity
| Tool | Description | Scope | Starter | Gate |
|---|---|---|---|---|
| `get_brain_identity` | Brain identity + counters for thin-client banner. | read | | |
| `whoami` | Introspect the calling identity. | read | yes | |
## ingest
| Tool | Description | Scope | Starter | Gate |
|---|---|---|---|---|
| `get_ingest_log` | Get recent ingestion log entries | read | yes | |
| `log_ingest` | Log an ingestion event | write | | |
## insights
| Tool | Description | Scope | Starter | Gate |
|---|---|---|---|---|
| `find_anomalies` | Returns statistical anomalies in recent page activity, grouped by cohort (tag or type). | read | yes | |
| `find_contradictions` | v0.32.6 — return suspected-contradiction findings from the most recent `gbrain eval suspected-contradictions` probe run, optionally filtered by slug and/or severity. | read | | |
| `find_experts` | Answers 'who in my brain knows about <topic>'. | read | | |
| `find_trajectory` | v0.35.4 — return the chronological claim trajectory for an entity (typed metric values over time, plus auto-detected regressions and narrative drift). | read | | |
| `get_calibration_profile` | Read the active calibration profile for a holder. | read | | |
| `get_recent_salience` | Returns pages recently touched and ranked by emotional + activity salience (deterministic 0..1 emotional_weight + take density + recency decay). | read | yes | |
| `volunteer_context` | Push-based context: volunteer brain pages relevant to a rolling conversation window WITHOUT being asked. | read | | |
## jobs
| Tool | Description | Scope | Starter | Gate |
|---|---|---|---|---|
| `cancel_job` | Cancel a waiting, active, or delayed job | admin | | |
| `get_agent_job` | Poll an agent job submitted via submit_agent. | agent | yes | |
| `get_job` | Get job status and details by ID | admin | | |
| `get_job_progress` | Get structured progress for a running job | admin | | |
| `list_jobs` | List jobs with optional filters | admin | | |
| `pause_job` | Pause a waiting, active, or delayed job | admin | | |
| `replay_job` | Replay a completed/failed/dead job, optionally with modified data | admin | | |
| `resume_job` | Resume a paused job back to waiting | admin | | |
| `retry_job` | Re-queue a failed or dead job for retry | admin | | |
| `send_job_message` | Send a sidechannel message to a running job's inbox | admin | | |
| `submit_agent` | Submit an LLM agent job that the worker dispatches via the gateway-native tool loop. | agent | yes | |
| `submit_job` | Submit a background job to the Minions queue. | admin | | |
## links
| Tool | Description | Scope | Starter | Gate |
|---|---|---|---|---|
| `add_link` | Create link between pages | write | | |
| `find_orphans` | Find pages with no inbound wikilinks. | read | | |
| `get_backlinks` | List incoming links to a page | read | yes | |
| `get_links` | List outgoing links from a page | read | | |
| `list_link_sources` | List distinct link_source provenances in the brain with edge counts (e.g. | read | yes | |
| `remove_link` | Remove link between pages | write | | |
| `traverse_graph` | Traverse link graph from a page. | read | yes | |
## memory
| Tool | Description | Scope | Starter | Gate |
|---|---|---|---|---|
| `extract_facts` | v0.31: extract personal-knowledge facts (events, preferences, commitments, beliefs) from a conversation turn into the per-source hot memory. | write | | |
| `forget_fact` | v0.32.2: forget a fact. | write | | |
## memory-verbs
| Tool | Description | Scope | Starter | Gate |
|---|---|---|---|---|
| `context_pack` | MEMORY VERB (v1): budget-packed session-boundary bundle for a set of standing entities — entity cards + open threads + hot facts, zero-LLM, sub-second. | read | yes | |
| `delta` | MEMORY VERB (v1): "what changed since T" for heartbeats — pages updated after `since` + hot facts newer than `since` + open-thread events after `since`, zero-LLM. | read | yes | |
| `entity` | MEMORY VERB (v1): inspect ONE known person/company/project card — zero LLM calls, sub-100ms. | read | yes | |
| `forget` | MEMORY VERB (v1): expire a remembered fact by id — the protocol delete verb. | write | yes | |
| `recall` | MEMORY VERB (v1): retrieve saved facts/snippets — the protocol read verb. | read | yes | |
| `remember` | MEMORY VERB (v1): save one fact to durable agent memory — the protocol write verb. | write | yes | |
| `synthesize` | [EXPENSIVE / SLOW — makes LLM calls, seconds-to-minutes latency, costs money] MEMORY VERB (v1): answer a broad question using cross-page LLM reasoning with citations and gap analysis. | read | yes | |
## ontology
| Tool | Description | Scope | Starter | Gate |
|---|---|---|---|---|
| `ontology_conflicts` | Life Chronicle: dimensions with ≥2 distinct current values from ≥2 provenances (genuine disagreement, not temporal supersession). | read | | |
| `ontology_dimensions` | Life Chronicle meta-ontology: which dimensions the brain tracks across entities, with entity + observation counts. | read | | |
| `ontology_get` | Life Chronicle: the current resolved per-entity ontology (dimension → value) at `asof` (default now), with provenance + confidence + validity. | read | | |
| `ontology_propose` | Life Chronicle: record one ontology observation (entity has dimension=value), sourced + confidence-weighted + bi-temporal. | write | | |
## pages
| Tool | Description | Scope | Starter | Gate |
|---|---|---|---|---|
| `delete_page` | Soft-delete a page. | write | | |
| `get_chunks` | Get content chunks for a page | read | | |
| `get_page` | Read a page by slug (supports optional fuzzy matching). | read | yes | |
| `get_raw_data` | Retrieve raw data for a page | read | | |
| `get_versions` | Page version history | read | | |
| `list_pages` | List pages with optional filters. | read | yes | |
| `put_page` | Write/update a page (markdown with frontmatter). | write | yes | |
| `put_raw_data` | Store raw API response data for a page | write | | |
| `resolve_slugs` | Fuzzy-resolve a partial slug to matching page slugs | read | yes | |
| `restore_page` | v0.26.5 — restore a soft-deleted page (clear deleted_at). | write | | |
| `revert_version` | Revert page to a previous version | write | | |
## schema
| Tool | Description | Scope | Starter | Gate |
|---|---|---|---|---|
| `get_active_schema_pack` | v0.40.6.0: cheap identity packet for the active schema pack. | read | | |
| `list_schema_packs` | v0.40.6.0: list installed schema packs (bundled + user-installed). | read | | |
| `reload_schema_pack` | v0.40.6.0: flush the in-process schema pack cache so the next loadActivePack re-reads from disk. | admin | | |
| `schema_apply_mutations` | v0.40.7.0: batched schema pack mutation. | admin | | |
| `schema_explain_type` | v0.40.6.0: resolved settings for a single page_type in the active pack. | read | | |
| `schema_graph` | v0.40.6.0: schema pack graph as JSON edges. | read | | |
| `schema_lint` | v0.40.6.0: lint the active (or named) schema pack. | read | | |
| `schema_review_orphans` | v0.40.6.0: list pages with no active-pack type match. | read | | |
| `schema_stats` | v0.40.6.0: per-type page counts + typed-coverage from the DB. | read | | |
## search
| Tool | Description | Scope | Starter | Gate |
|---|---|---|---|---|
| `query` | Hybrid search with vector + keyword + multi-query expansion. | read | yes | |
| `search` | Cheap hybrid search (vector + keyword + RRF) with no LLM expansion. | read | yes | |
| `search_by_image` | v0.36 cross-modal Phase 2: image-as-query retrieval. | read | | |
## skills
| Tool | Description | Scope | Starter | Gate |
|---|---|---|---|---|
| `get_skill` | Fetch one skill's full instructions by name. | read | | `mcp.publish_skills` |
| `list_brain_skillpack` | List brain-resident skillpacks this brain ships (per-source). | read | | `mcp.publish_skills` |
| `list_skills` | List the skills this agent's brain publishes. | read | | `mcp.publish_skills` |
## sources
| Tool | Description | Scope | Starter | Gate |
|---|---|---|---|---|
| `sources_add` | Register a new source. | sources_admin | | |
| `sources_list` | List registered sources with page counts and remote_url. | read | | |
| `sources_remove` | Hard-remove a source (cascades pages/chunks/embeddings). | sources_admin | | |
| `sources_status` | Per-source diagnostic. | read | | |
## tags
| Tool | Description | Scope | Starter | Gate |
|---|---|---|---|---|
| `add_tag` | Add tag to page | write | | |
| `get_tags` | List tags for a page | read | | |
| `remove_tag` | Remove tag from page | write | | |
## takes
| Tool | Description | Scope | Starter | Gate |
|---|---|---|---|---|
| `takes_calibration` | Calibration curve: resolved correct/incorrect bets binned by stated weight; observed vs predicted per bucket. | read | | |
| `takes_list` | List takes (typed/weighted/attributed claims) filtered by holder/kind/active/etc. | read | | |
| `takes_scorecard` | Calibration scorecard for resolved bets: counts, accuracy, Brier (correct incorrect only), partial_rate. | read | | |
| `takes_search` | Keyword search across takes (pg_trgm similarity over claim text) | read | | |
| `think` | Multi-hop synthesis across pages + takes + graph. | read | | |
## timeline
| Tool | Description | Scope | Starter | Gate |
|---|---|---|---|---|
| `add_timeline_entry` | Add timeline entry to a page | write | yes | |
| `get_timeline` | Get timeline entries for a page, optionally filtered by date window | read | | |
+18
View File
@@ -1,5 +1,23 @@
# ZeroEntropy — zembed-1 + zerank-2
> **Hosted API shutdown: 2026-09-04.** ZeroEntropy announced (2026-07-24)
> that its hosted endpoints — `/models/embed` and `/models/rerank` — shut
> down on that date. A brain still embedding through the hosted API loses
> semantic retrieval entirely on that date: query embedding uses the same
> endpoint, so **existing vectors become unqueryable**, not just new
> content. Two fixes, either works:
>
> 1. **Self-host the same model** — zembed-1 weights are Apache-2.0. Serve
> them via `llama-server` or Ollama and point the config at the local
> endpoint. Keeps every existing vector; no re-embed at all.
> 2. **Migrate to another provider** — `gbrain migrate embeddings --to
> <provider:model> --dim <N> --dry-run` (resumable; see
> [the migration guide](../guides/embedding-migration.md)). `gbrain
> doctor` (check `provider_sunset`) prints this command with your
> brain's actual `--dim` filled in.
>
> The hosted setup below remains accurate until the shutdown date.
[ZeroEntropy](https://zeroentropy.dev) ships two specialized small models
for retrieval pipelines:
File diff suppressed because one or more lines are too long
+10
View File
@@ -10,6 +10,16 @@ empty local PGLite, so a populated remote brain can't silently return
"No results." Local-only commands refuse with a pinpoint hint instead of
falling through.
**Surface posture:** thin clients stay FULL-surface. The thin-client CLI routes
arbitrary `gbrain <op>` invocations over MCP, so a narrowed per-client surface
(`oauth_clients.surface`, WP4) would break commands the install legitimately
owns — bootstrap pins `--surface full` on its serve registrations and operators
should keep thin-client OAuth rows at `full` (or NULL). The stdio transport has
no client row at all: it serves the server-resolved surface directly, and the
per-client ceiling machinery (`effectiveSurfaceForClient`) applies only to the
OAuth HTTP transport. The starter/verbs narrowing is for agent-harness clients,
not for thin-client installs.
Key files (per-file detail lives in each file's `KEY_FILES.md` entry; this doc
carries the routing-seam picture):
+149 -10
View File
@@ -20,9 +20,11 @@ follows is `BOOTSTRAP_FOR_AGENTS.md` at the repo root, fetched at the
| `agent.json` manifest + `brain/`, `memory/`, `skills/`, `state/` | workspace | — |
| Local brain (PGLite) | `~/.gbrain/` (never in the repo) | while a session's MCP serve is open |
| MCP registration (`gbrain serve`) | Claude Code: project scope by default; Codex: user-global (no scope flag) | spawned by your harness per session |
| Hooks (Claude Code, ON by default) | `.claude/settings.local.json` (gitignored) | each prompt; fail-open; `--no-hooks` opts out at install, `GBRAIN_HOOKS=0` disables at runtime |
| Session persistence | SessionEnd hook → scan-gated commit+push | at session end |
| Optional 15-min push job | launchd/cron (consent-gated) | while logged in |
| Hooks (Claude Code, ON by default) | local installs: `.claude/settings.local.json` (gitignored); cloud sandboxes: the COMMITTED `.claude/settings.json` (PATH-resolved, fail-open commands) | each prompt; fail-open; `--no-hooks` opts out at install, `GBRAIN_HOOKS=0` disables at runtime |
| Per-turn persistence | Stop hook → debounced, detached scan-gated push (per workspace; 5 min default, every turn in cloud sandboxes) | after each assistant turn; `GBRAIN_STOP_PUSH=0` disables; `GBRAIN_STOP_PUSH_DEBOUNCE_MIN` / config `hooks.stop_push_debounce_min` tune it |
| Session persistence | SessionEnd hook → scan-gated commit+push | at session end (note: the harness never fires SessionEnd on `/exit` — the per-turn push is what covers that) |
| Push-failure visibility | next turn's context + a user-visible notice; re-announces every 30 min while failing | whenever a background push fails |
| Optional background job (consent-gated) | git post-commit auto-push + launchd/cron 30-min pull (pull job skipped honestly on hosts without a scheduler) | while logged in |
| Private GitHub repo | your account, created by `bootstrap repo` (or an empty repo you made yourself, adopted) | privacy verified via API |
| Machine receipt | `~/.gbrain/bootstrap/receipt.json` | uninstall is keyed to it |
@@ -30,6 +32,36 @@ follows is `BOOTSTRAP_FOR_AGENTS.md` at the repo root, fetched at the
schedules fire at turn/session boundaries only. True 24/7 operation is what a
hosted brain provides — this is the honest desktop contract.
## Cloud sandboxes (claude.ai/code and similar)
Cloud sessions run in a reclaimed-after-inactivity VM behind a
credential-injecting egress proxy. `gbrain bootstrap status --json` reports
`execution_environment: "cloud-sandbox"` there, and the install adapts:
- **Hooks live in the committed `.claude/settings.json`** with PATH-resolved,
fail-open commands (no machine paths). The gitignored local settings file
never survives into the next session's fresh clone, and hook config is
snapshotted at session start — so hooks written mid-session go live on the
NEXT session. Commit and push the file.
- **The per-turn push runs every turn** (debounce 0) — a reclaimed VM's tail
loss is permanent, so each turn banks to the private repo.
- **Repo-privacy verification falls back to pure git protocol** when the proxy
blocks the GitHub API (GraphQL is always pinned there; REST reaches only
session-attached repos). Confirmed-public origins still always refuse.
- **Repo creation is refused in cloud** with the flow that works: create the
private repo from a normal machine or github.com, open the cloud session ON
that repo, run `gbrain bootstrap attach`.
- **The gbrain binary installs via the environment setup script** — print it
with `gbrain bootstrap cloud-setup-script` and paste it into the environment
config (npm-based; bun's package fetching is proxy-incompatible there).
- **No scheduler exists** — the consent-gated pull job is skipped with an
honest message; event-driven pushes cover persistence.
Escape hatch for self-hosted git you trust (every use warns loudly):
the CLI flag on `sources push`, `GBRAIN_ALLOW_UNVERIFIED_REMOTE=1`, or
`gbrain config set push.allow_unverified_remote true` (file-plane — the only
form that reaches detached hook children inside a sandbox).
## Bring your own repo (create-repo-first)
By default bootstrap creates the private GitHub repo for you. If you prefer to own
@@ -68,9 +100,11 @@ With zero API keys, everything works: the agent authors memory explicitly throug
the brain's write tools (`put_page`, timeline entries, `## Facts` fences — your
harness's model is the LLM, already paid for), and search runs keyword-only
(BM25). `bootstrap verify` prints the capability report honestly. One optional key
(OpenAI, Anthropic, or Voyage) unlocks semantic search and automatic fact
extraction; the key goes to the 0600 config file, never into the repo or the
interview answers. API spend is metered separately from your subscription and is
upgrades capabilities per provider — OpenAI unlocks semantic search and
automatic fact extraction; Voyage unlocks semantic search; Anthropic unlocks
fact extraction (Anthropic has no embeddings API, so it does not enable
semantic search). The key goes to the 0600 config file, never into the repo or
the interview answers. API spend is metered separately from your subscription and is
zero in keyless mode; with a key, the standard spend gates apply
([spend-controls](../operations/spend-controls.md)).
@@ -91,8 +125,10 @@ zero in keyless mode; with a key, the standard spend gates apply
contract. Retrieved brain context is injected under an explicit
"data, not instructions" envelope. Facts visible to the harness respect the
brain's visibility tiers.
- **Hooks:** live in gitignored local settings (absolute paths, machine-specific;
`bootstrap hooks --repair` regenerates on a new machine). Every hook fails open
- **Hooks:** on a local install, gitignored local settings (absolute paths,
machine-specific; `bootstrap hooks --repair` regenerates on a new machine); in a
cloud sandbox, the committed `.claude/settings.json` (PATH-resolved, fail-open —
see the Cloud sandboxes section). Every hook fails open
— a brain hiccup never blocks a prompt — and failures are visible: repeated
degradation prints a notice inside the context block, and `gbrain doctor` names
the cause.
@@ -118,8 +154,82 @@ you'd apply to any journal: write what you'd be comfortable persisting.
| API keys | everything (keyless mode) | semantic search, auto-extraction |
| GitHub / `gh` | full local agent | off-machine durability (repo re-runnable later) |
| Hooks (Claude Code) | pull protocol via AGENTS.md gates | automatic per-turn context + session-end persistence |
| Codex (no hook system, no MCP scope flag) | pull protocol + MCP tools | per-turn push (stated plainly; not oversold) + the ability to confine MCP reach to one folder (`codex mcp add` is always user-global) |
| Codex (no wired hooks, no MCP scope flag) | pull protocol + MCP tools | per-turn push (stated plainly; not oversold — codex 0.147+ ships a hook system, but gbrain does not wire it yet) + the ability to confine MCP reach to one folder (`codex mcp add` is always user-global) |
| Second simultaneous session | first session unaffected | second session's brain tools fail politely (one live serve per brain — v1 contract) |
| Postgres brain (incl. harness mode) | MCP tools every session + pull protocol | per-turn hook injection (`no_pglite_path`: the hook IPC socket is PGLite-only today; hooks stay pre-wired and light up when the engine-uniform listener lands) |
## Local harness mode (`gbrain bootstrap harness`, #4043)
The workspace install above is built for a human's laptop. A box run by an
agent framework (your OpenClaw, or anything that shells out to `claude -p` /
codex exec) already hosts a brain and a running `gbrain serve --http` — and
those framework-spawned sessions get zero brain access by default. Harness
mode wires them in one command, with no `agent.json` and no interview:
gbrain bootstrap harness --yes
- Mints a **least-privilege** bearer token (scopes `read+write`, stored in the
`access_tokens.scopes` column; reads span the brain's federated sources).
Re-runs rotate mint-first: the previous token is revoked by id only after
the new one is wired and smoke-tested, so clients are never dead mid-swap.
The smoke sends a deliberately invalid credential first — an endpoint that
accepts anything is not this brain's serve — and a failed smoke rolls the
wiring back (fresh registrations removed, replaced ones restored, the
headless pre-approval stripped) and retires the fresh mint immediately, so
nothing live is ever left pointed at an unverified endpoint. Prior wiring
is only cleaned up after the replacement verifies.
- Claude Code: user-scope HTTP MCP registration, `mcp__gbrain` pre-approved in
user-scope `permissions.allow` (headless `claude -p` blocks MCP tools
without it), and the five lifecycle hooks — user scope by default, or
exactly the dirs you pass with repeatable `--project` (never both; the two
would double-fire every event). `--no-capture` wires context injection only
and skips the transcript-capture events.
- Codex: one managed `[mcp_servers.gbrain]` block with the bearer token
INLINE in the codex config (0600) — framework-spawned codex inherits no
shell profile, so the env-var lane the `connect` path uses would never
reach it.
- Honesty on Postgres brains: per-turn injection is degraded (the matrix row
above); MCP is the active seam and the summary says so.
- `--status [--json]` probes the live truth (serve health, token validity via
host-config recovery — the Claude Code lane only recovers a bearer from a
registration whose URL matches the receipt; the codex managed block is read
from the exact path the receipt records — and per-target states) with a
cron-honest exit contract: 0 only when the serve, token, and every target
verify and the rotation has converged (honest degrades count as OK); 1 on
an unreachable serve, a failed token verify, failed or pending targets, an
unconverged rotation, or a half-removed install whose token still awaits
revocation. With no install at all it says so and exits 0 (2 under
`--json`, so machine callers can tell absence apart). `gbrain doctor`
carries a matching `bootstrap_harness_health` check. `--json` on the
install itself emits a single machine-readable document on stdout (prose
goes to stderr).
- The full flag surface lives in `gbrain bootstrap --help`: `--url`/`--port`
point at a non-default serve (a non-loopback `--url` is refused unless you
also pass `--token`, which flips into registrar mode — MCP wiring only, no
hooks, nothing minted), `--force` replaces a foreign same-name MCP
registration, `--name` renames the server, `--harness` picks the hosts,
and `--no-hooks` skips hook wiring entirely.
- `--remove` tears down exactly what the machine-level receipt
(`<home>/bootstrap/harness.json`) records — host removals are engine-free
and run even while a serve is live; the token revoke defers with exact
instructions if a live PGLite serve holds the brain. `gbrain bootstrap
uninstall` removes harness wiring first, automatically.
- Everything is stated before it happens; non-interactive runs require
`--yes`. Close active Claude Code sessions for the cleanest user-scope
settings writes (the host also writes that file).
PGLite note: minting needs the single-writer lock, so on a PGLite brain
either pre-mint (`gbrain auth create bootstrap-harness --scopes read,write`
while the serve is stopped) and pass `--token`, or stop/re-run/restart.
Postgres brains mint fine while the serve runs. A token you supply is never
revoked by `--remove` or rotation (it is not the harness's to revoke) —
retire it yourself with `gbrain auth revoke` when you're done with it.
Binary-downgrade note: token scoping is data-only (no migration), so a gbrain
binary OLDER than the release that shipped it verifies every scoped token as
FULL-ACCESS — the old verify path never reads the scopes column. If you
downgrade after a harness install, revoke the scoped tokens first
(`gbrain auth revoke` with the id flag) and re-mint once you upgrade again.
## Multi-device
@@ -159,7 +269,7 @@ that changed shape, a harness that stopped calling our MCP server):
keyless-`init` → interview → render → `gbrain bootstrap hooks --harness codex`
path (executing the real `codex mcp add` into a hermetic `~/.codex/config.toml`),
asserts the rendered `AGENTS.md` carries the Gate-3 brain-first pull protocol
(Codex has no hook system, so the pull protocol is its per-turn seam), then
(gbrain does not wire Codex hooks yet, so the pull protocol is its per-turn seam), then
spends one live `codex exec` turn to prove real codex → gbrain MCP → brain →
a seeded, brain-only fact (falling back to a shell `gbrain query` if headless
stdio-MCP is unavailable).
@@ -180,3 +290,32 @@ Run locally (where both are installed + authed):
```bash
bun test test/e2e/bootstrap-real-codex.serial.test.ts
```
## DX exploration harness (developer instrument, not a test)
The door tests prove the install WORKS; they say nothing about how it FEELS.
`test/helpers/tty-harness.ts` spawns any CLI (gbrain, `claude`, `codex`) under a
real pseudo-terminal (Bun's `terminal:` spawn option) and records every output
burst with a millisecond timestamp, so unnecessary pauses become a measurable
artifact (`computeStalls``stalls.md`) instead of a vibe. Same hermetic env as
`agent-harness.ts`; pure helpers are unit-tested in `test/tty-harness.test.ts`
(zero subprocesses, PTY smokes self-skip where `terminal:` is unavailable).
`scripts/dx-explore.ts` drives it to capture the fresh-user funnel as timestamped
transcripts under `.context/dx-runs/` (gitignored — nothing asserts, no CI):
```bash
bun run scripts/dx-explore.ts help # comprehension surfaces (no keys)
bun run scripts/dx-explore.ts init [--keyless] # interactive init, naive-user autopilot
bun run scripts/dx-explore.ts claude-install # REAL claude running the paste-in bootstrap
bun run scripts/dx-explore.ts codex-install # REAL codex, same
bun run scripts/dx-explore.ts drive -- gbrain init # manual: steer a live TUI via a file channel
```
`drive` mode is how an agent in a Conductor workspace explores a live TUI across
separate tool calls: `cat <dir>/session/screen.txt` to watch, append
`{"line":"..."}` / `{"key":"Down"}` / `{"stop":true}` to `<dir>/session/input.jsonl`
to steer. Each run writes `meta.json`, `visible.txt`, `frames.jsonl`, and
`stalls.md`. `--keyless` strips provider keys so the true no-key first-touch path
is exercised (a Conductor session's ambient `ANTHROPIC_API_KEY` would otherwise
leak in). Install scenarios pay real API cost — launch them as background tasks.
+37
View File
@@ -27,6 +27,37 @@ gbrain migrate embeddings --to voyage:voyage-3-large --yes
declared width and is required for recipes that don't declare one (litellm,
llama-server, and other bring-your-own-model providers).
**Pick `--dim` = your brain's current column width when the target supports
it.** A different width triggers the destructive schema transition (column +
index rebuild across all three dim-pinned tables); the same width skips it
entirely. `gbrain doctor` (check `provider_sunset`, for providers with an
announced shutdown) prints the paste-ready command with your actual width
already filled in — it reads the real `vector(N)` column, not the config
value, which can drift.
## How affected brains find out (provider sunsets)
Two surfaces flag a brain whose embedding model (or reranker) is on a
provider with an announced hosted-API shutdown, such as ZeroEntropy
(2026-09-04):
- **`gbrain doctor`** — the `provider_sunset` check warns on every run until
the brain is off the provider. After the shutdown date it escalates to
`fail` only when embedded vectors actually exist on the dead provider
(retrieval is genuinely down); a zero-vector brain whose config merely
resolves to the dead default stays `warn`, so doctor-as-CI-gate setups
don't start exiting 1 on the date. The reranker side resolves through the
same plane search actually reranks with (the mode bundle +
`search.reranker.*` overrides). The message carries the paste-ready
migration command with the brain's actual `--dim`. Accepted the risk?
`gbrain config set doctor.suppress_provider_sunset true` silences it.
- **`gbrain upgrade`** — a one-shot banner (gated by
`ze_sunset_notice_shown`) with the same two fixes.
Both state the full consequence: after the shutdown, **existing vectors
become unqueryable** — query embedding uses the same endpoint as ingestion —
not just new content.
## What it does, in order
1. **Plan.** Counts every chunk not already in the target embedding space —
@@ -88,6 +119,12 @@ continues where it stopped. An in-flight marker (`embedding_migration.state`
in DB config) records the target; it is cleared only when the backlog drains
to zero.
One caveat after a HARD kill (SIGKILL, crash, power loss — not Ctrl-C): the
run's per-source single-flight embed lock is left behind, and an immediate
re-run skips the re-embed and reports the migration as paused. The command
says so explicitly (`lock_skipped` in `--json`); the lock expires on its own
after at most 60 minutes, then the same re-run resumes normally.
A page whose chunks straddle two stale batches is embedded correctly but not
stamped by the embed loop (which only stamps all-or-nothing per batch), so the
migration runs one reconcile pass after the drain that stamps every
+5
View File
@@ -53,6 +53,11 @@ gbrain reindex-search-vector --dry-run # preview: language + row counts
gbrain reindex-search-vector --yes # recreate triggers + backfill
```
The stamp survives later schema work: `initSchema()` — including the replay
behind `gbrain init --migrate-only` on every upgrade — applies the schema
template under the configured language, so it re-creates the trigger
functions as they already are instead of reverting them to `english`.
The command recreates both trigger functions under the new language and
backfills every existing `pages` and `content_chunks` row in batches,
streaming progress to stderr. It is idempotent: re-running with the same
+6 -1
View File
@@ -20,7 +20,12 @@ schema. The user gets new capabilities automatically.
gbrain stays current the way gstack does: it rides invocation frequency. A
throttled, cache-read-only check runs at the start of every `gbrain` invocation
(CLI and MCP) and emits an `UPGRADE_AVAILABLE <old> <new>` marker on stderr. No
(CLI and MCP) and emits an `UPGRADE_AVAILABLE <old> <new>` marker on stderr. The
raw marker line is suppressed when stderr is an interactive TTY (a human sees
only the plain `gbrain X -> Y available` sentence, not the machine token); set
`GBRAIN_FORCE_UPGRADE_MARKER=1` if an agent harness parses the token but runs
under a PTY. `<old>` is always the RUNNING binary's version, so a stale or
foreign-written cache never nags about an upgrade this binary already has. No
host cron required — every agent kind (Claude Code, Codex, OpenClaw, Hermes, the
`gbrain serve` host behind a Perplexity thin client) converges to current by
construction. The behavior is governed by one file-plane config key,
+3 -1
View File
@@ -23,7 +23,7 @@ The resolved provider + dimensions get persisted to `~/.gbrain/config.json` atom
| Provider | env vars | default dims | cost ($/1M tokens) | local? | multimodal? |
|---|---|---|---|---|---|
| `zeroentropyai` | `ZEROENTROPY_API_KEY` | 2560 (Matryoshka to 1280/640/320/...) | 0.05 | no | no |
| `zeroentropyai` (hosted API **shuts down 2026-09-04** — see note below) | `ZEROENTROPY_API_KEY` | 2560 (Matryoshka to 1280/640/320/...) | 0.05 | no | no |
| `openai` | `OPENAI_API_KEY` | 1536 | 0.13 | no | no |
| `openrouter` | `OPENROUTER_API_KEY` | 1536 | 0.02 | no | model-dependent |
| `voyage` | `VOYAGE_API_KEY` | 1024 | 0.18 | no | yes (`voyage-multimodal-3`) |
@@ -42,6 +42,8 @@ The resolved provider + dimensions get persisted to `~/.gbrain/config.json` atom
**Note on local providers.** Ollama and llama-server have no required API key, so they don't show up in env-detection auto-pick. Pick them explicitly with `--embedding-model ollama:<model>` to avoid silently routing to a daemon that may not be running.
**Note on the ZeroEntropy hosted API.** ZeroEntropy announced (2026-07-24) that its hosted endpoints shut down on **2026-09-04**. A brain still embedding through the hosted API loses semantic retrieval entirely on that date — query embedding uses the same endpoint, so existing vectors become unqueryable, not just new content. Either self-host the Apache-2.0 zembed-1 weights via llama-server/Ollama (keeps every existing vector, no re-embed), or migrate with `gbrain migrate embeddings` — see [the migration guide](../guides/embedding-migration.md). `gbrain doctor` (check `provider_sunset`) flags affected brains and prints the paste-ready command with the brain's actual `--dim` filled in.
## If first import fails
If `gbrain import` fails with `expected N dimensions, not M`, run `gbrain doctor`. The output will print the exact `gbrain config set ...` or `gbrain retrieval-upgrade` command to repair the mismatch. **You should not need to delete `~/.gbrain`.** The bug-class that historically forced `rm -rf` recoveries is closed as of v0.37.
+5 -3
View File
@@ -20,9 +20,11 @@ claude mcp add gbrain -- gbrain serve --surface verbs
That's it. Claude Code spawns `gbrain serve` as a stdio subprocess. No server, no
tunnel, no token needed. Works with both PGLite and Supabase engines.
`--surface verbs` exposes the five-verb memory protocol (`recall`, `remember`,
`entity`, `synthesize`, `forget` — [MEMORY_VERBS v1](../protocol/MEMORY_VERBS_v1.md)),
the surface built for agents and quickstarts. Drop the flag for the full
`--surface verbs` exposes the seven-verb memory protocol (`recall`, `remember`,
`entity`, `synthesize`, `forget`, `context_pack`, `delta`
[MEMORY_VERBS v1](../protocol/MEMORY_VERBS_v1.md)),
the surface built for agents and quickstarts. `--surface starter` adds the
daily-driver set on top (~26 ops total). Drop the flag for the full
operation catalog (`get_page`, `put_page`, `search`, graph ops, …) — `full` is
the default and what existing installs already run.
+6 -1
View File
@@ -11,7 +11,12 @@
Recent versions of the Codex CLI (`@openai/codex`) support remote
streamable-HTTP MCP servers with a bearer token read from an environment
variable. The token lives in your shell env, not in Codex's config file.
variable. On THIS page's `gbrain connect` path the token lives in your shell
env, not in Codex's config file. The exception is `gbrain bootstrap harness`
(local agent-framework boxes): framework-spawned codex inherits no shell
profile, so that lane writes the token INLINE into a managed, 0600
`[mcp_servers.gbrain]` block in the codex config — stated in its consent
block, removable with `gbrain bootstrap harness --remove`.
## Fastest path: `gbrain connect`
+34 -10
View File
@@ -4,9 +4,11 @@
> PKCE, refresh rotation, optional DCR), an embedded React admin dashboard at
> `/admin`, scoped operations, and a live SSE activity feed. Legacy bearer
> tokens still work — `verifyAccessToken` falls back to the `access_tokens`
> table and grandfathers tokens to `read+write+admin`. Both the legacy fallback
> and the OAuth tables work on PGLite and Postgres (both engine schemas carry
> `access_tokens`). See [SECURITY.md](../../SECURITY.md) for env vars and
> table; tokens with no `scopes` grant are grandfathered to `read+write+admin`,
> while tokens minted with `gbrain auth create --scopes …` (or by
> `gbrain bootstrap harness`) are honored at exactly their granted scopes.
> Both the legacy fallback and the OAuth tables work on PGLite and Postgres
> (both engine schemas carry `access_tokens`). See [SECURITY.md](../../SECURITY.md) for env vars and
> tunable defaults.
Access your brain from any device, any AI client. GBrain ships two transports:
@@ -24,9 +26,10 @@ gbrain serve --surface verbs # just the 7 memory verbs (quickstart surface)
Works with Claude Code, Cursor, Windsurf, and any MCP client that supports stdio.
No server, no tunnel, no token needed. Works on both PGLite and Postgres engines.
`--surface verbs` exposes exactly the five-verb memory protocol (`recall`,
`remember`, `entity`, `synthesize`, `forget`
`--surface verbs` exposes exactly the seven-verb memory protocol (`recall`,
`remember`, `entity`, `synthesize`, `forget`, `context_pack`, `delta`
[MEMORY_VERBS v1](../protocol/MEMORY_VERBS_v1.md)) instead of the full catalog;
`--surface starter` sits between (~26 ops: the verbs plus the daily-driver set);
omit the flag (default `full`) for every operation.
### Remote over OAuth 2.1 (recommended)
@@ -67,8 +70,9 @@ This requires:
2. A public tunnel (ngrok, Tailscale, or cloud host)
3. A bearer token created via `gbrain auth create <name>`
Existing bearer tokens are grandfathered as `read+write+admin` scopes on the
OAuth-capable HTTP server, so no migration is required.
Existing bearer tokens (no `scopes` grant) are grandfathered as
`read+write+admin` on the OAuth-capable HTTP server, so no migration is
required; `gbrain auth create --scopes read,write` mints narrowed tokens.
## OAuth 2.1 Setup
@@ -163,6 +167,22 @@ await oauthProvider.registerClientManual(
For self-service client registration (Dynamic Client Registration, RFC 7591),
start the server with `--enable-dcr`. DCR is off by default.
DCR requests may include an optional `token_ttl_seconds` field (integer,
seconds) to request a per-client access-token lifetime. The server clamps the
request into an admin-configured window — never rejects over it — persists the
effective value as the client's TTL override, and echoes it back as
`token_ttl_seconds` in the registration response. Subsequent `/token` responses
for that client carry the matching `expires_in`. Clients that omit the field
keep the server default (`--token-ttl`). The window defaults fail-closed: min
300 seconds, max bounded by your `--token-ttl` — a self-registering client
cannot request a longer-lived token than the server default unless you
explicitly widen the window:
```bash
gbrain config set oauth.dcr_ttl_min_seconds 600
gbrain config set oauth.dcr_ttl_max_seconds 86400
```
### 3. Expose the server
**Bind explicitly.** `gbrain serve --http` defaults to `127.0.0.1`.
@@ -206,7 +226,8 @@ Write ops can additionally be fenced per client with `--bound-slug-prefixes`
## Legacy Bearer Token Setup
Bearer tokens are the simple path when you don't need per-client scoping.
They grandfather to `read+write+admin` scopes on the HTTP server.
Without a `--scopes` grant they grandfather to `read+write+admin` on the
HTTP server; pass `--scopes read,write` at creation to narrow one.
### 1. Set up the tunnel
@@ -232,8 +253,11 @@ gbrain auth list
gbrain auth revoke "claude-desktop"
```
Tokens are per-client. Create one for each device/app. Revoke individually
if compromised. Tokens are stored SHA-256 hashed in your database.
Tokens are per-client. Create one for each device/app. Names are not
unique: `gbrain auth revoke "<name>"` revokes EVERY active token carrying
that name — use `gbrain auth list` (shows each token's id and scopes) and
`gbrain auth revoke --id <uuid>` to revoke exactly one. Tokens are stored
SHA-256 hashed in your database.
### 3. Connect your AI client
+112
View File
@@ -0,0 +1,112 @@
# Hermes CLI pin — observed behavior notes (v0.20.0)
Dev-facing companion to [HERMES.md](HERMES.md): every fact below was OBSERVED
against a real install (2026-08-12), not researched from docs. The claw-test
HermesRunner, the install door e2e, and the heavy-tests hermes-door CI job
assert exactly these shapes — when hermes releases change them, update this
file, the workflow pins, and the affected assertions together.
## Pin
- **Hermes Agent v0.20.0 (2026.8.3)**, observed against git checkout `3e09adb` at
`~/.hermes/hermes-agent` (an upstream-main commit carrying the same v0.20.0/2026.8.3
version stamp; CI installs the RELEASE TAG `v2026.8.3` = commit `3c27eb62` — the two
differ by post-release main commits, same declared version. If a CI door run ever
diverges from these notes, re-observe against the tag checkout.)
- Installer sha256: `c118ff31618dc70339049ce71061b8f1351a1c70d9c2a236ed50d8a2550c550d`
(download https://hermes-agent.nousresearch.com/install.sh to a file first; verify; then run)
- Installer flags used: `--skip-setup --non-interactive`; binary lands at `~/.local/bin/hermes`
- Python 3.11.15 via uv
## HERMES_HOME — HONORED (verified)
Installer (`HERMES_HOME="${HERMES_HOME:-$HOME/.hermes}"`) AND runtime both honor it:
`mcp add`/`mcp list`/`config set` under `HERMES_HOME=<tmp>` read+write `<tmp>/config.yaml`,
populate `<tmp>/{SOUL.md,cron,logs,...}`, and do NOT touch `~/.hermes`. Belt-and-suspenders
(HOME + HERMES_HOME both to tmp) stays in the door test anyway.
## One-shot (`-z`)
- `hermes -z "<prompt>"`**stdout = final text ONLY**; benign notices may appear on stderr
("Shell cwd was reset to ..."). Verified reply fidelity ("B0-PROBE-OK").
- Exit codes: 0 = success; **1 = no inference provider configured** (message: "agent failed:
No inference provider configured. Run 'hermes model' ... or set an API key
(OPENROUTER_API_KEY, OPENAI_API_KEY, etc.) in ~/.hermes/.env.")
- `--usage-file PATH` exists; per-call `-m MODEL --provider PROVIDER` exist; also
`--in DIR`, `--ignore-user-config`, `--safe-mode`, `-t TOOLSETS`, `--skills`.
## Auth + model pin (non-interactive)
- `$HERMES_HOME/.env` with `ANTHROPIC_API_KEY=...` WORKS (verified end-to-end).
- Model pin: `hermes config set model.default anthropic/claude-haiku-4.5` → exit 0,
writes `model.default` into config.yaml. `hermes config get model.default` reads it back.
(`hermes model` is INTERACTIVE-only — never use it in tests/CI.)
- Valid model id format: `anthropic/claude-haiku-4.5` (hermes catalog naming, provider-prefixed).
## `hermes mcp add` — THE big observed facts
- Shape: `hermes mcp add <name> [--env K=V K2=V2 ...] [--connect-timeout N] --command CMD --args ...`
**`--args` MUST be the last option** — anything after it (incl. a misplaced `--env`) is
swallowed into the server argv. (First rehearsal failed exactly this way.)
**The env flag takes MULTIPLE KEY=VALUE values after ONE flag; REPEATING it REPLACES the
first occurrence** (argparse nargs semantics) — a repeated-flag invocation silently drops
the earlier vars, the handshake fails, and the piped Y then hits the save-anyway prompt →
the entry is saved with `enabled: false`. (First real door run failed exactly this way.)
- Add performs a REAL MCP handshake + tool discovery at add time. Against
`--command bun --args run <abs>/src/cli.ts serve` with `--env GBRAIN_HOME=<tmp>`:
connected, discovered **110 gbrain tools**.
- On success it prompts `Enable all N tools? [Y/n/select]:` — **non-interactive: pipe
`printf 'Y\n'`**. Piping Y saves: `✓ Saved 'gbrain' to <HERMES_HOME>/config.yaml (110/110
tools enabled)`. EOF on the prompt = `Cancelled.`, nothing saved.
- **EXIT CODE IS 0 EVEN ON CONNECTION FAILURE OR CANCEL.** Never assert on `mcp add`'s exit
code. Hard assertions = (a) `config.yaml` contains `mcp_servers.<name>` after the add,
(b) `hermes mcp test <name>` exits 0.
## Saved config schema (verbatim shape)
```yaml
_config_version: 34
mcp_servers:
gbrain:
command: bun
args:
- run
- /abs/path/src/cli.ts
- serve
env:
GBRAIN_HOME: /tmp/gb-xxxx
connect_timeout: 60.0
enabled: true
```
(The generated file also contains commented template blocks — security, fallback_model.)
## Probes
- `hermes mcp list` → table `Name / Transport / Tools / Status`, row shows `gbrain ... ✓ enabled`.
- `hermes mcp test gbrain` → exit 0 + prints the tool list. THE targeted probe for Test 1b.
- `hermes doctor` exists (global health; not a per-server assertion).
## Cron (for the post-pin F7 TODO — real test is buildable)
`hermes cron create [--name NAME] [--deliver ...] [--repeat N] [--skill S] [--script PATH]
[--no-agent] [--workdir DIR] [--model M] [--provider P] <schedule> [prompt]` — fully
non-interactive. `hermes cron tick` = run due jobs once and exit. `hermes cron list` exists.
## CI pin values (heavy-tests.yml `hermes-door` job)
- `HERMES_VERSION: "0.20.0"`
- `HERMES_GIT_TAG: "v2026.8.3"` + `HERMES_GIT_COMMIT: "3c27eb6234bf91b8ceee9e9071591b31e9b148cb"`
the installer's `--branch`/`--commit` flags pin the cloned PAYLOAD (the sha256 below only
pins the installer script; without the tag+commit the payload would be upstream main).
The flags are asserted, not trusted: post-install the job runs
`git -C ~/.hermes/hermes-agent rev-parse HEAD` and loud-fails on any mismatch, so an
installer that silently ignores unknown flags (or a moved checkout layout) can never
run unpinned upstream code on a runner that later holds secrets.
- `HERMES_INSTALL_SHA256: "c118ff31618dc70339049ce71061b8f1351a1c70d9c2a236ed50d8a2550c550d"`
- Door test asserts `hermes --version` output contains `v$HERMES_VERSION` when the env var is set.
- `hermes --version` output shape: `Hermes Agent v0.20.0 (2026.8.3)` + install dir + python lines.
## Multi-provider 401 gotcha (door hermeticity)
With `model.default` pinned to `anthropic/*` but a SECOND provider key visible (env or
.env — e.g. `OPENAI_API_KEY`), hermes's provider-auto mis-routes the request and the turn
returns `HTTP 401: Missing Authentication header` as final text with EXIT 0. The door
suite therefore seeds exactly ONE key (anthropic) and scrubs all provider env vars from
hermes children (`hermesChildEnv` in test/helpers/agent-harness.ts) — the seeded
`$HERMES_HOME/.env` is the single auth source.
## mcp add save-anyway (correction to an earlier note)
A piped `Y` saves the entry EVEN when the handshake failed — the save-anyway prompt
writes it with `enabled: false`. The success discriminators are `enabled: true` in the
saved YAML plus `hermes mcp test <name>` exit 0 — never the add's exit code, and not the
mere presence of the config entry.
+120
View File
@@ -0,0 +1,120 @@
# Connect GBrain to Hermes
> This page is the MCP-registration reference for Hermes (the NousResearch
> `hermes-agent`). For the full brain install — CLI, engine, skills, dream
> cycle — follow [INSTALL_FOR_AGENTS.md](../../INSTALL_FOR_AGENTS.md) first;
> this page wires the finished brain into Hermes over stdio MCP.
Hermes spawns `gbrain serve` as a local stdio subprocess. No server, no tunnel,
no token needed. Works with both PGLite and Supabase engines.
## Register (recommended)
```bash
printf 'Y\n' | hermes mcp add gbrain --env GBRAIN_HOME=$HOME --connect-timeout 60 --command $(which gbrain) --args serve
```
`hermes mcp add` performs a real MCP handshake and tool discovery at add time,
then prompts `Enable all N tools? [Y/n/select]:`. Three gotchas, all observed:
- **`--args` must be the LAST option.** Everything after it — including a
misplaced `--env` — is swallowed into the server argv. To pass several
environment variables, list them all after ONE `--env` flag
(`--env A=1 B=2`); repeating the flag replaces the earlier values and the
server is saved disabled when its handshake then fails. Put `--env` and
`--connect-timeout` before `--command`, exactly as above.
- **Pipe the `Y` in non-interactive contexts.** EOF on the enable-tools prompt
prints `Cancelled.` and saves nothing. The piped `Y` saves the server with
all tools enabled.
- **The exit code is 0 even on connection failure or cancel.** Never assert on
`mcp add`'s exit status — verify with `hermes mcp list` and
`hermes mcp test gbrain` (below).
## Direct config (equally supported)
The add command writes an `mcp_servers` block into `$HERMES_HOME/config.yaml`
(default `~/.hermes/config.yaml`). You can write it yourself instead:
```yaml
mcp_servers:
gbrain:
command: gbrain
args:
- serve
env:
GBRAIN_HOME: /home/alice-example
connect_timeout: 60.0
enabled: true
```
To remove gbrain, delete this block (or set `enabled: false` to disable
without losing the config).
## Verify
```bash
hermes mcp list # table row: gbrain ... ✓ enabled
hermes mcp test gbrain # exits 0 and prints the discovered tool list
```
Then one real round-trip:
```bash
hermes -z "ask my gbrain brain: what did I import most recently?"
```
`hermes -z` prints the final answer on stdout (benign notices may appear on
stderr). Inside Hermes, gbrain's tools appear namespaced as
`mcp_gbrain_<tool>` (e.g. `mcp_gbrain_search`).
## Headless auth + model pin
For cron jobs, CI, or any non-TTY run, Hermes needs a provider key and a
default model configured without the interactive picker:
- Put the key in `$HERMES_HOME/.env`:
```bash
ANTHROPIC_API_KEY=sk-ant-...
# or OPENROUTER_API_KEY / OPENAI_API_KEY
```
- Pin the model non-interactively (`hermes model` is interactive-only — never
use it in scripts or CI):
```bash
hermes config set model.default anthropic/claude-haiku-4.5
hermes config get model.default # reads it back
```
## Pair with cron
Hermes cron is fully non-interactive, which makes it a natural scheduler for
brain maintenance:
```bash
hermes cron create --name gbrain-sync '0 */4 * * *' 'Run gbrain sync and report anything unusual'
hermes cron tick # run due jobs once and exit — deterministic testing
hermes cron list
```
See [docs/guides/cron-schedule.md](../guides/cron-schedule.md) for the full
brain maintenance protocol (sync, embed, dream cycle).
## Troubleshooting
- **`hermes doctor`** — global health check (installation, config, providers).
It's not a per-server assertion; use `hermes mcp test gbrain` for that.
- **`agent failed: No inference provider configured`** (exit 1) — Hermes has
no model key. Set one in `$HERMES_HOME/.env` and pin `model.default` as
above.
- **Relocating Hermes** — both the installer and the runtime honor
`HERMES_HOME`. All state (`config.yaml`, `.env`, `SOUL.md`, cron, logs)
lives under it; the default is `~/.hermes`. Export it consistently or the
gbrain registration lands in a config file the runtime never reads.
---
Documented against **Hermes Agent v0.20.0 (2026.8.3)**. Dev-facing observed-behavior
notes (exact flag semantics, exit-code caveats, CI pin values) live in
[HERMES-CLI-PIN.md](HERMES-CLI-PIN.md).
+62
View File
@@ -0,0 +1,62 @@
# Connect GBrain to OpenClaw
> This page is the MCP-registration reference card. For the full brain install
> — CLI, engine, skills, dream cycle — follow
> [INSTALL_FOR_AGENTS.md](../../INSTALL_FOR_AGENTS.md); the README covers the
> bootstrap and connect paths.
Two supported shapes, both stdio.
## Option 1: ClawHub bundle plugin
GBrain ships [`openclaw.plugin.json`](../../openclaw.plugin.json) at the repo
root. Installing the bundle plugin registers the MCP server for you — the
manifest carries an `mcpServers.gbrain` entry (`./bin/gbrain serve`) plus the
bundled skills — and declares the `gbrain-context` context engine. To route
OpenClaw's context-engine slot through gbrain, set:
```
plugins.slots.contextEngine = gbrain-context
```
## Option 2: Direct `~/.openclaw/config.json`
The same shape gbrain's own CI uses (see the "Configure OpenClaw MCP" step in
`.github/workflows/e2e.yml`):
```json
{
"mcpServers": {
"gbrain": {
"command": "gbrain",
"args": ["serve"],
"env": {
"DATABASE_URL": "postgresql://...",
"GBRAIN_HOME": "/home/alice-example"
}
}
}
}
```
The `env` block is optional: a PGLite brain needs no `DATABASE_URL`, and
`GBRAIN_HOME` only matters when the brain home isn't `~/.gbrain`. Append
`"--surface", "verbs"` to `args` for the seven-verb memory protocol
([MEMORY_VERBS v1](../protocol/MEMORY_VERBS_v1.md)) instead of the full
operation catalog.
## Verify
Start an agent turn and ask it to use the brain:
```
Call get_brain_identity, then search my brain for [topic].
```
If the tools respond, the wiring works. `list_skills` shows everything the
brain can do (gated by `mcp.publish_skills` on the host).
## Remove
Delete the `mcpServers.gbrain` block from `~/.openclaw/config.json`, or
uninstall the bundle plugin.
+211
View File
@@ -0,0 +1,211 @@
# MCP surface runbook
Operator moves for the remote MCP surface (the truthful-surface wave:
honest per-token tools/list, per-client surfaces, strict-params grace
period, STARTER_OPS). Current behavior only; release history lives in
`CHANGELOG.md` + git. Companion references: the generated
[`docs/TOOL_CATALOG.md`](../TOOL_CATALOG.md) (every non-localOnly op with
scope/starter/gate), `docs/protocol/MEMORY_VERBS_v1.md` (surface modes),
`docs/protocol/MCP_META_CHANNELS.md` (`_meta` conventions).
Everything below assumes `gbrain serve --http` (the OAuth transport).
tools/list is recomputed **per request** — none of these moves needs a
server restart unless it says so.
## Move 1 — flip a publish gate
Gated ops (`Operation.publishGateKey`): `list_skills` / `get_skill` /
`list_brain_skillpack` (`mcp.publish_skills`) and `advisor`
(`mcp.publish_advisor`). Both gates default OFF: the ops are hidden from
remote tools/list AND denied at call time.
```bash
gbrain config set mcp.publish_skills true # or mcp.publish_advisor
```
**Expected outcome:** the very next tools/list from any token includes the
gated ops (dual-plane read, DB > file, per request — no restart). Flipping
back to `false` hides them again on the next list; the call-time backstop
denies immediately with the machine-readable detail
`config_key=mcp.publish_skills`. A failed gate READ during tools/list
resolves to hidden (fail-closed consent posture), never a failed list.
## Move 2 — rescope a client's surface
```bash
gbrain auth clients --usage # who needs it? (op counts, surface, last seen)
gbrain auth rescope-client <client_id> --surface starter # verbs | starter | full | clear
```
Usage counts only successful calls (`success` / `success_with_warnings`) —
a client flooding denials or errors shows zero usage, so denied traffic can
never argue its way into a wider surface or the starter derivation.
**Expected outcomes:**
- The client's NEXT request resolves the new surface (per-request
ceiling-bounded resolution: `min(server --surface ceiling, client row)`)
— the client must re-issue tools/list to see the change; cached tool
lists in a long-lived session go stale until it does.
- An audit row lands in `mcp_request_log` (`operation='surface_change'`,
params carrying actor/old/new/via) — every surface mutation writes one
(rescope CLI, admin endpoint, request_tools persist):
```sql
SELECT created_at, params FROM mcp_request_log
WHERE operation = 'surface_change' ORDER BY created_at DESC LIMIT 10;
```
- A CLI rescope sets `surface_set_by='operator'` — the operator lock:
`request_tools` persist cannot override it. (The persist itself is
rate-limited per client and meters actual writes only — `dry_run`
previews are free.)
- The advisor's `mcp-client-fit` collector proposes exactly this command
for full-surface clients whose 30d usage fits STARTER_OPS.
**Default for NULL-surface clients** (including future DCR
self-registrations):
```bash
gbrain config set mcp.default_surface_dcr starter # verbs | starter | full
```
Dual-plane read (DB > file), applied on each client's next request,
ceiling-bounded like everything else; unset means NULL-surface clients
resolve to the server ceiling (pre-wave behavior). Pre-seed important
clients with an explicit `rescope-client --surface full` before flipping it.
## Move 3 — flip strict params from warn to reject
`mcp.strict_params` governs unknown-argument handling at dispatch:
`warn` (default) accepts the call, surfaces `_meta.warnings` + a
model-visible notice block, and logs the success as
`status='success_with_warnings'`; `reject` returns `invalid_params` with
did-you-mean suggestions.
**Flip criterion (evidence-based, amendment 13):** near-zero
`success_with_warnings` rows over 30 days of production traffic —
```sql
SELECT count(*) FROM mcp_request_log
WHERE status = 'success_with_warnings'
AND created_at > now() - interval '30 days';
```
When that count is ~0, clients have adapted; flip:
```bash
gbrain config set mcp.strict_params reject
```
**Expected outcome (schema emission change):** besides rejecting unknown
args, tools/list schemas change shape — each `inputSchema` closes with
`additionalProperties: false` and declares the `_meta`/`dry_run`
passthrough keys (D14.1), keeping schema-validating clients aligned with
the server's reject posture. Read per request; flipping back to `warn`
reopens the schemas on the next list. A transient config-read failure
cannot re-open the grace period: dispatch holds the last successfully
read mode per process, so a reject-mode server stays reject through a
config outage. `test/mcp-tool-defs.test.ts` pins
both emission states; the default stays `warn` until the project-level
flip (see TODOS.md, strict_params reject-flip).
## Move 4 — change STARTER_OPS
```bash
bun run scripts/derive-starter-ops.ts [--days 30]
```
reads production `mcp_request_log` through the shared usage reader
(automation-shaped clients excluded, per-client DISTINCT-op sets weighted
by client count), prints a proposed daily-driver block with a provenance
header. Paste it into `src/mcp/surface.ts` (replacing
`FALLBACK_DAILY_OPS`) — the script never edits files. Then:
```bash
bun test test/mcp-surface.test.ts # membership + monotonicity: verbs ⊆ starter ⊆ full
bun run scripts/generate-tool-catalog.ts # refresh the Starter column; freshness guard fails CI otherwise
```
`VERB_NAMES` + `whoami` + `request_tools` + the agent lane are composed in
`surface.ts` and always included — the derivation only proposes the daily
slice. The advisor's drift finding (`mcp_starter_ops_drift`) is the
standing prompt to re-run this move.
## Incident levers
- **`GBRAIN_MCP_FORCE_SURFACE=verbs|starter|full`** — narrow-only clamp
(FOV-6a): it `min()`s into every resolved surface and can NEVER widen
past the configured ceiling; widening requires an explicit `--surface`
restart. Use it to clamp a misbehaving deployment down to verbs without
touching client rows.
- **`GBRAIN_SEARCH_SALVAGE=off`** — restores pre-wave all-or-nothing
retrieval (no allSettled salvage, strict budget, no minKeep failsafe)
if the fail-loud retrieval behavior itself misbehaves.
**Total embed outage, what to expect (ENG-6):** the query cache is
uncacheable by construction during a full embedding outage — `query_cache`
keys on embedding similarity, and both store and lookup no-op on a null
embedding. Expect cache hit rate ~0 (`gbrain search stats`) and
keyword-only degraded results carrying `_meta.retrieval.degraded` stages
plus the model-visible block on empty results. This is the designed
degradation, not a second incident; only PARTIAL degradations (expansion
failed, vector arm failed) get short-TTL cache entries.
## The honest-catalog metric (trend to zero)
The wave's working metric (amendment 33): op-level call-time denials the
tools/list filter should have made impossible. serve-http logs them as
`status='denied_after_list'` — scope denials, publish-gate backstop
denials (`config_key=...`), and bound-client fence OP-level denials
(`fence=op`). Argument-level slug-fence denials are legitimate for a
listed op and excluded (D10).
```sql
SELECT count(*) FROM mcp_request_log
WHERE status = 'denied_after_list'
AND created_at > now() - interval '30 days';
```
A non-zero trend means list-time and call-time predicates drifted (a bug)
or a client is calling ops it was never shown (staleness/guessing) —
either way, worth a look at the offending rows' `token_name` + `operation`.
## First 5 minutes after a deploy
Migrate-then-serve is atomic per process (initSchema runs before listen).
Post-deploy checks, in order:
1. **tools/list count per token class** — for each token class you run
(admin/full, read/starter, agent-only, slug-bound): list tools and eyeball
the count (starter ≈ the STARTER_OPS size, full ≈ the TOOL_CATALOG count,
agent-only = its minimal lane). Counts are also queryable:
`SELECT token_name, params->>'tool_count' FROM mcp_request_log WHERE operation='tools/list' ORDER BY created_at DESC LIMIT 10;`
2. **Empty-query probe shows the degraded block** — call `search` with a
nonsense query; the empty result must carry a second content block
("0 results. … clean miss." or degraded stages) + `_meta.retrieval`.
3. **Workerless submit warns**`submit_agent` while no worker runs must
still succeed and carry `queue_state.warning` (worker_alive false).
4. **put_page lint fields present** — put an uncited page; the response
must carry `writer_lint.top_findings` (or the zero-findings shape).
### As a smoke-tests.d drop-in
The smoke-test skill runs user scripts from `~/.gbrain/smoke-tests.d/*.sh`.
Save the four checks as a drop-in (fill in URL + token):
```bash
#!/usr/bin/env bash
# ~/.gbrain/smoke-tests.d/check-remote-mcp.sh — truthful-surface deploy checks
set -euo pipefail
URL="${GBRAIN_MCP_URL:?set GBRAIN_MCP_URL}"; TOK="${GBRAIN_MCP_TOKEN:?set GBRAIN_MCP_TOKEN}"
call() { curl -sf "$URL" -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' -d "$1"; }
# 1. tools/list responds and reports a sane count
N=$(call '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' | grep -o '"name"' | wc -l)
[ "$N" -gt 0 ] && echo "OK tools/list: $N tools" || { echo "FAIL tools/list"; exit 1; }
# 2. empty search carries the model-visible degradation block
call '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"search","arguments":{"query":"zzqx-no-such-thing-xkcd"}}}' \
| grep -q '0 results' && echo 'OK empty-result loudness' || { echo 'FAIL empty-result block'; exit 1; }
# 3+4 need write/agent scopes — run only when the token has them:
# submit_agent → response contains "queue_state"; put_page → "writer_lint".
```
+4
View File
@@ -135,6 +135,10 @@ sync that calls import emits `sync.import.<file>`, not `import.<file>`.
Stable phase names shipped in v0.15.2:
- `doctor.db_checks` (umbrella for all DB-side doctor checks)
- `doctor.pglite_probe` (the #2674 scratch-store probe; only when PGLite init
failed with an unexplained/damage-class disk state or `--probe-pglite` was
passed — a cold start can take 520s, so the heartbeat is the only sign of
life)
- `orphans.scan`
- `embed.pages`
- `extract.links_fs`, `extract.timeline_fs`, `extract.links_db`, `extract.timeline_db`
+42
View File
@@ -0,0 +1,42 @@
# MCP `_meta` channels
Normative conventions for `ToolResult._meta` on gbrain's MCP surfaces
(WP2 amendment 9 / decision D3). `_meta` is the structured, out-of-band
channel for tool-call responses; the response BODY contract never changes
shape for it.
## Rules
1. **One producer per top-level key.** A producer owns exactly one
namespaced key and never writes another producer's key. The dispatch
layer (`src/mcp/dispatch.ts`) merges per top-level key — never wholesale
`_meta` assignment.
2. **Additive-forever within a key.** Fields inside a key may be added,
never renamed or removed — the RESPONSE_SCHEMAS discipline applied to
`_meta`. Consumers must tolerate unknown fields.
3. **Producer isolation.** Every producer attaches inside its own
try/catch. A failing producer degrades to its key being absent; it never
drops another producer's key and never errors the tool call.
4. **Merge precedence.** Handler-emitted keys (via
`OperationContext.emitResponseMeta`) attach first; transport hooks
(`metaHook`) attach after and may add keys but shadow nothing that
matters — key ownership (rule 1) makes ordering a non-event.
5. **Model visibility caveat.** Mainstream harnesses do NOT feed `_meta` to
the model. Anything the model must SEE rides a content block (see the D8
second text block on empty retrievals); `_meta` serves structured
programmatic consumers (thin clients, harness plumbing, tests).
## Registered keys
| Key | Producer | Contents |
|-----|----------|----------|
| `brain_hot_memory` | serve-http `metaHook` (`getBrainHotMemoryMeta`) | Hot-memory facts relevant to the call (v0.31 eD3) |
| `retrieval` | `search`/`query` op handlers | `returned_count`, `retrieved_count`, `vector_enabled`, `expansion_applied`, `cache`, `token_budget`, `degraded[]` (closed stage vocabulary, D6), `hint` (non-contractual prose, E1) |
| `warnings` | dispatch strict-params warn mode (WP3) | `[{code: 'unknown_param', param, suggestion?}]` |
Inbound `_meta` (e.g. `_meta.session_id` inside tool ARGUMENTS, CX2-11) is a
separate, client-to-server plane. The eval-report `_meta.metric_glossary`
lives in JSON BODIES of eval commands — a third, unrelated plane. Ambient
recall (#4028) rides content/hooks, not `_meta`.
Adding a key: register it in the table above, one producer, additive-forever.
+52 -4
View File
@@ -71,10 +71,28 @@ each client.
**Surface modes:** `--surface verbs` exposes EXACTLY the seven verbs —
advertised list AND dispatch are filtered fail-closed (a hidden op returns
`unknown_tool` even when called by name). `--surface full` (the default)
exposes every operation, verbs included. Why default full: verbs is for
agents and quickstarts; full preserves existing advanced tooling. Persist a
default with `gbrain config set mcp_surface verbs`.
`unknown_tool` even when called by name). `--surface starter` exposes the
~26-op daily-driver set (`STARTER_OPS` in `src/mcp/surface.ts`): the seven
verbs plus the daily brain-tool slice, the agent lane, `whoami`, and the
`request_tools` discovery meta-op (re-derivable from production usage via
`scripts/derive-starter-ops.ts`). Monotonic by construction: verbs ⊆ starter ⊆ full
(pinned by test) — starter extends the ladder ABOVE verbs and never changes
verb semantics. `--surface full` (the default) exposes every operation,
verbs included. Why default full: verbs/starter are for agents and
quickstarts; full preserves existing advanced tooling. Persist a default
with `gbrain config set mcp_surface verbs`.
**Ceiling semantics (OAuth HTTP transport):** the server-resolved surface
is a CEILING, not the final answer. Each request resolves
`min(ceiling, client row surface ?? mcp.default_surface_dcr ?? ceiling)`
so a verbs-pinned server always serves verbs regardless of client rows,
while a full server can narrow individual clients
(`gbrain auth rescope-client <id> --surface starter`) or let them narrow
themselves via `request_tools` (never past the ceiling; an operator-set
row is locked against self-service). Recomputed per request — rescopes
take effect on the client's next request; clients should re-issue
tools/list after a surface change. stdio and the legacy bearer transport
have no per-client row: they serve the server-resolved surface directly.
## The verbs
@@ -183,6 +201,36 @@ output_tokens, usd_estimate}, protocol_version }`.
- No LLM configured ⇒ the protocol error `unavailable` with a fix — never a
fake answer.
#### synthesize compose status (v0.45.x, additive)
Every response additionally carries four ADDITIVE-FOREVER fields (absent on
pre-v0.45.x servers; a server that omits them still certifies):
- `synthesis_status` — how `answer` was produced: `ok` (LLM synthesis) or
`extractive_fallback` (the LLM compose step failed but retrieval succeeded —
`answer` is an extractive digest quoting ONLY retrieved pages, `sources`
cite the digested pages). The remaining enum values (`empty_answer`,
`not_json`, `no_llm`, `model_unusable`, `llm_error`) name compose-failure
states a non-verb `think` surface may report; the verb converts them to the
fallback or a typed error and never emits them itself.
- `pages_gathered` / `takes_gathered` — retrieval counts behind the answer.
- `warnings` — machine-stable pipeline warning codes (e.g.
`LLM_OUTPUT_NOT_JSON`, `SYNTHESIS_EMPTY_ANSWER`, `LLM_CALL_FAILED: <class>`
where `<class>` is one of the closed set `timeout` | `rate_limited` |
`network` | `provider_error` — raw provider detail never rides the wire,
`MODEL_NOT_USABLE:<reason>`).
Precedence (frozen): compose failure + NON-EMPTY gather ⇒
`extractive_fallback` — the digest is composed exclusively from gathered
pages, never fabricated. Compose failure + EMPTY gather ⇒ the protocol error
`unavailable` with message `retrieved 0 pages; compose failed: <warning-code>`
(an empty gather NEVER produces an answer). Provider/transport failures at
call time (429 / timeout / 5xx / network) are caught into `llm_error` and
follow the same precedence. No LLM configured stays the `unavailable`
configure-and-retry error regardless of gather — an extractive digest would
mask the misconfiguration forever. Refusals parse as `not_json` (coarse on
purpose, no dedicated status).
### forget(id, reason?) — write
Expire a fact by its opaque string id (from `remember` or
+7 -6
View File
@@ -156,11 +156,12 @@ codex mcp add gbrain -- gbrain serve --surface verbs
That's the whole wire-up. No token, no URL, no tunnel. The agent spawns
`gbrain serve` as a stdio subprocess and talks to your local brain directly.
`--surface verbs` exposes exactly the five-verb memory protocol
(`recall`, `remember`, `entity`, `synthesize`, `forget`
`--surface verbs` exposes exactly the seven-verb memory protocol
(`recall`, `remember`, `entity`, `synthesize`, `forget`, `context_pack`, `delta`
[MEMORY_VERBS v1](../protocol/MEMORY_VERBS_v1.md), frozen + additive-forever)
instead of the full operation catalog, so the agent sees a tight, stable surface
instead of a 110-tool wall. Drop the flag (or pass `--surface full`) for every
instead of a 110-tool wall. `--surface starter` sits between: the verbs plus the
daily-driver set (~26 ops total). Drop the flag (or pass `--surface full`) for every
operation. The default when the flag is omitted is `full`, so existing wire-ups
are unchanged.
@@ -192,12 +193,12 @@ about people, companies, decisions, projects, or past context:
tokens → `search` (cheap hybrid, no expansion). Concept, landscape, or
"all the X that do Y" questions → `query` FIRST — it recovers synonym
phrasings `search` misses, and a populated `search` result set is not proof
of coverage. On the five-verb surface the same split is `recall` (retrieve)
of coverage. On the verbs surface the same split is `recall` (retrieve)
vs `synthesize` (reasoned answer). Check the brain BEFORE answering from
memory or asking me. Never ask "who is X?" or "what did we decide about Y?"
before checking — the brain probably already knows.
2. **Write back.** When I make a decision, mention a new person/company, or land
on an idea worth keeping, write it to the brain: `remember` on the five-verb
on an idea worth keeping, write it to the brain: `remember` on the verbs
surface (one fact, with provenance), or `put_page` on the full surface
(entity pages under people/, companies/; decisions under decisions/ or
notes/). One insight, one page, linked.
@@ -222,7 +223,7 @@ hundreds of linked pages and patterns you didn't know were there.
**3. Briefing from your brain (not from the internet).** *"What do I need to know
before my 2pm with the Acme team?"* pulls your meeting history, the people,
what's still open, what the brain doesn't know yet. The agent does your prep
because it read your context. (`query``synthesize` on the five-verb surface —
because it read your context. (`query``synthesize` on the verbs surface —
gives you the synthesized answer with citations; this is the example on the
[README](../../README.md).)
+114 -24
View File
@@ -661,7 +661,9 @@ four numeric segments are required first. Historical 3-segment versions
| `CHANGELOG.md` | Top entry header `## [0.31.4.1] - YYYY-MM-DD` plus the "To take advantage of v0.31.4.1" block. | Standard Keep-a-Changelog header. |
| `TODOS.md` | Any TODO entries that mention "follow-up from vX.Y.Z.W" use the version of the release that filed them. Update only when filing NEW follow-up TODOs. | Inline `vX.Y.Z.W` references in TODO bodies. |
| `CLAUDE.md` | The Key Files section's per-file annotations carry `vX.Y.Z.W (#NNN)` tags noting which release introduced a behavior. Update whenever a wave's annotations get folded in. | Inline `vX.Y.Z.W (#NNN, contributed by @user)` references. |
| `openclaw.plugin.json` | OpenClaw plugin manifest (v0.45.6.0, #4033). Hand-maintained; `test/openclaw-plugin-manifest.test.ts` fails the suite if it drifts from `package.json`. Merges from master auto-resolve it to master's version — re-bump it with the trio. | `"version": "0.45.7.0"` |
| `openclaw.plugin.json` | OpenClaw plugin manifest (v0.45.6.0, #4033). Hand-maintained; `test/openclaw-plugin-manifest.test.ts` fails the suite if it drifts from `package.json`. Merges from master auto-resolve it to master's version — re-bump it with the trio. | `"version": "0.45.12.0"` |
| `BOOTSTRAP_FOR_AGENTS.md` | Runbook stamp on line 1. `scripts/check-bootstrap-tag.sh` (in `bun run verify` + CI) fails when it drifts from `VERSION`; refresh it in the same commit as the bump. | `<!-- gbrain-runbook-stamp: X.Y.Z.W -->` |
| `templates/bootstrap/template-repo/` | Vendored template tree with an embedded version stamp. Auto-derived, but NOT by `bun install`: run `bun run scripts/generate-template-repo.ts --out templates/bootstrap/template-repo` after the bump; `scripts/check-bootstrap-templates.sh` fails CI on drift. | `<!-- gbrain-template-stamp: X.Y.Z.W -->` in generated files. |
**Auto-derived (no manual edit; refreshed by their own commands):**
@@ -1104,9 +1106,10 @@ NOT inside ~/gbrain.
## Step 3.5: Confirm search mode with the user (DO NOT SKIP)
`gbrain init` auto-applied a default search mode (`tokenmax` unless your subagent
tier is Haiku-class or no OpenAI key is configured). The init output included the
cost matrix below preceded by `[AGENT]` markers. You must NOT silently accept the
default. Stop and ask the operator.
tier is Haiku-class or no expansion-capable API key — Anthropic, OpenAI, or
Google — is configured). The init output included the cost matrix below preceded
by `[AGENT]` markers. You must NOT silently accept the default. Stop and ask the
operator.
**Present this matrix verbatim:**
@@ -1230,6 +1233,16 @@ diff against gbrain's bundle when you want upstream improvements. (The legacy
`gbrain skillpack install` managed-block model was removed in v0.33 — run
`gbrain skillpack migrate-fence` once if upgrading from an older release.)
**If you are Hermes:** register gbrain as your MCP server:
```bash
printf 'Y\n' | hermes mcp add gbrain --env GBRAIN_HOME=$HOME --connect-timeout 60 --command $(which gbrain) --args serve
```
Keep `--args` last (everything after it becomes server argv) and verify with
`hermes mcp test gbrain` — the add exits 0 even on failure. Full reference:
[docs/mcp/HERMES.md](docs/mcp/HERMES.md).
Whether you scaffolded or not, read `skills/RESOLVER.md` (in your workspace, or the
bundled copy at `~/gbrain/skills/RESOLVER.md` when running from the cloned repo). It's
the skill dispatcher — tells you which skill to read for any task. Save this to your
@@ -1508,7 +1521,8 @@ wins; fix the row.
|---------|-------|
| "Set up GBrain", first boot | `skills/setup/SKILL.md` |
| "Now what?", "fill my brain", "cold start", "bootstrap my data", "import my data", "what should I import first" | `skills/cold-start/SKILL.md` |
| "Install gbrain into this agent/harness", "agent workspace bootstrap", "gbrain bootstrap", "wire gbrain hooks", "set up the maintenance sweep" | Run `gbrain bootstrap` (paste-in harness install: hooks + sweep + config). See `docs/guides/bootstrap.md` |
| "agent workspace bootstrap", "install gbrain into this agent workspace", "gbrain bootstrap", "paste-in install", "set up the maintenance sweep" | Run `gbrain bootstrap` (paste-in workspace install: interview + identity files + hooks + sweep). See `docs/guides/bootstrap.md` |
| "wire this box's coding agents to the brain", "framework-spawned sessions need brain access", "wire gbrain hooks without a workspace", "hook Claude Code/Codex to the running serve" | Run `gbrain bootstrap harness --yes` (machine-level wiring to a running `serve --http`: scoped token + user-scope MCP + headless pre-approval + hooks; no agent.json). See the "Local harness mode" section of `docs/guides/bootstrap.md` |
| "Migrate from Obsidian/Notion/Logseq" | `skills/migrate/SKILL.md` |
| Brain health check, maintenance run | `skills/maintain/SKILL.md` |
| "Extract links", "build link graph", "populate timeline" | `skills/maintain/SKILL.md` (extraction sections) |
@@ -1594,7 +1608,7 @@ The point of building a 150K-page brain is to use it as a strategic moat. To nev
It's easier to ship a daemon that runs 24/7 to ingest, enrich, and consolidate than it is to keep an agent in chat working hard. GBrain is that daemon, generalized. Install in 30 minutes. Your agent does the work. As my personal agent gets smarter, so does yours.
> **~30 minutes to a fully working brain.** Database ready in 2 seconds (PGLite, no server). You just answer questions about API keys.
> **~15 minutes to a working personal agent** on the recommended Codex / Claude Code path (mostly a short interview); ~30 minutes for the always-on OpenClaw / Hermes setup. Database ready in 2 seconds either way (PGLite, no server).
> **LLMs:** fetch [`llms.txt`](llms.txt) for the documentation map, or [`llms-full.txt`](llms-full.txt) for the same map with core docs inlined in one fetch. **Agents:** start with [`AGENTS.md`](AGENTS.md) (or [`CLAUDE.md`](CLAUDE.md) if you're Claude Code).
@@ -1669,7 +1683,9 @@ answers. Ask before anything destructive. You are not done until
`gbrain bootstrap verify` exits 0.
```
Codex will ask for command approvals during the install — approving them is the sandbox working as intended. What you get, in about 15 minutes: a short interview (6 required questions) → your agent's identity (SOUL.md, USER.md, MEMORY.md) rendered from your own answers, never invented → a local PGLite brain (2 seconds, no server, no Docker) → MCP wired so every session can search and write memory → a **private** GitHub repo, created and privacy-verified, as your agent's durable body. Works with **zero API keys** — keyword search plus memory your agent writes itself; one optional key (OpenAI, Anthropic, or Voyage) upgrades to semantic search and automatic fact extraction. Codex reads brain context through its tools each turn (pull-based).
Codex will ask for command approvals during the install — approving them is the sandbox working as intended. What you get, in about 15 minutes: a short interview (6 required questions) → your agent's identity (SOUL.md, USER.md, MEMORY.md) rendered from your own answers, never invented → a local PGLite brain (2 seconds, no server, no Docker) → MCP wired so every session can search and write memory → a **private** GitHub repo, created and privacy-verified, as your agent's durable body. Works with **zero API keys** — keyword search plus memory your agent writes itself; one optional key upgrades capabilities (OpenAI: semantic search + automatic fact extraction; Voyage: semantic search; Anthropic: fact extraction). Codex reads brain context through its tools each turn (pull-based). The click moment: tell it one small thing to remember, restart Codex, then ask for it back — the answer comes from the brain, not from this chat's context (which the restart cleared). That cross-session round-trip is the whole product; "what's my name / my top jobs?" is answered from your identity files, which is nice but not the same trick.
Two things worth understanding once it's running: **you own the brain** — every memory is a markdown file in that private repo (read it, clone it to a second machine, delete it and the brain is gone) — and **the first skill to run is `cold-start`**: say "fill my brain" and your agent imports your Gmail, calendar, and contacts (via [ClawVisor](https://clawvisor.com), an OAuth vault so the agent never holds raw tokens) or offline archives like Google Takeout, one consented step at a time. An empty brain is a database; a filled one is a memory.
> **Prefer to make the repo yourself?** Create a new **empty** private repo **under your own GitHub account** (no README/.gitignore/license), clone it, open the clone in Codex, and paste the same block — bootstrap detects your empty repo and adopts it instead of creating one. The repo must be empty and personal-account-owned; org-owned repos are refused (create one under your account, or let bootstrap make it).
@@ -1686,7 +1702,7 @@ answers. Ask before anything destructive. You are not done until
`gbrain bootstrap verify` exits 0.
```
Everything from the Codex path applies — interview, identity from your own answers, local brain, private repo, keyless mode — plus Claude Code gets **per-turn context hooks** (consent-gated): your brain loads automatically into every prompt, and each session persists itself to your private repo at exit. Restart the session after install and ask "what did I tell you my top jobs were?" — that's the moment it clicks. Full contract, security posture, and uninstall: [docs/guides/bootstrap.md](docs/guides/bootstrap.md).
Everything from the Codex path applies — interview, identity from your own answers, local brain, private repo, keyless mode — plus Claude Code gets **per-turn context hooks** (on by default, with an opt-out): your brain loads automatically into every prompt, and your work persists to your private repo on a per-turn cadence (debounced ~5 min locally, every turn in a cloud sandbox — this covers the `/exit` case the harness never fires a session-end hook on), with a notice on your next turn if a push ever fails. This works in a **Claude Code cloud session** too, not just on your laptop: verification falls back to pure git protocol when the sandbox blocks the GitHub API, and `gbrain bootstrap cloud-setup-script` prints the environment setup recipe. The click moment: tell it one small thing to remember, restart the session, then ask for it back — a fresh session has no chat context, so the answer can only come from the brain. That cross-session round-trip is the whole product ("what's my name?" is answered from your identity files — nice, but not the same trick). Same two follow-ups as the Codex path: you own the brain (markdown in your private repo), and `cold-start` is the first skill to run — "fill my brain" imports your email, calendar, and contacts (ClawVisor) or offline archives, one consented step at a time. Full contract, security posture, cloud sandboxes, and uninstall: [docs/guides/bootstrap.md](docs/guides/bootstrap.md).
> **Prefer to make the repo yourself?** Create a new **empty** private repo **under your own GitHub account** (no README/.gitignore/license), clone it, open the clone in Claude Code (CLI or the desktop app's open-a-repo flow), and paste the same block — bootstrap adopts your empty repo instead of creating one. The repo must be empty and personal-account-owned; org-owned repos are refused.
@@ -1710,7 +1726,7 @@ The agent installs GBrain, creates the brain, asks for your API keys, loads the
### Lighter ways in
**Just want a memory for your coding agent — no identity, no repo.** Spin up a local brain and connect it in two commands — zero server, zero token, zero tunnel. `--surface verbs` gives your agent the five-verb memory protocol (`recall`, `remember`, `entity`, `synthesize`, `forget` — [MEMORY_VERBS v1](docs/protocol/MEMORY_VERBS_v1.md), frozen + additive-forever) instead of the full tool wall; drop the flag for every operation:
**Just want a memory for your coding agent — no identity, no repo.** Spin up a local brain and connect it in two commands — zero server, zero token, zero tunnel. `--surface verbs` gives your agent the seven-verb memory protocol (`recall`, `remember`, `entity`, `synthesize`, `forget`, plus `context_pack` + `delta` since v0.45.7 — [MEMORY_VERBS v1](docs/protocol/MEMORY_VERBS_v1.md), frozen + additive-forever) instead of the full tool wall; drop the flag for every operation:
```bash
gbrain init --pglite # 2-second local brain (no Docker)
@@ -1749,6 +1765,8 @@ GBrain exposes nearly all of its 100+ operations as MCP tools (stdio and HTTP; a
- **[Claude Code](docs/mcp/CLAUDE_CODE.md)** — local: one command, `claude mcp add gbrain -- gbrain serve` (zero server, zero tunnel). Remote with just a bearer token: `gbrain connect https://your-host/mcp --token gbrain_xxx` prints a paste-ready block (or `--install` wires it up and smoke-tests the token).
- **[Codex](docs/mcp/CODEX.md)** — `gbrain connect https://your-host/mcp --token gbrain_xxx --agent codex` (or `--install`). Codex reads the bearer from `$GBRAIN_REMOTE_TOKEN` at runtime, so the token never lands in Codex config.
- **[Cursor / Windsurf / any stdio MCP client](docs/mcp/CLAUDE_CODE.md)** — same shape, add `{"command": "gbrain", "args": ["serve"]}` to your MCP config.
- **[Hermes](docs/mcp/HERMES.md)** — `printf 'Y\n' | hermes mcp add gbrain --env GBRAIN_HOME=$HOME --connect-timeout 60 --command $(which gbrain) --args serve`. Keep `--args` last, and verify with `hermes mcp test gbrain` (the add exits 0 even on failure).
- **[OpenClaw](docs/mcp/OPENCLAW.md)** — the ClawHub bundle plugin registers gbrain automatically (`openclaw.plugin.json` ships in this repo), or add `{"command": "gbrain", "args": ["serve"]}` to `~/.openclaw/config.json`'s `mcpServers`.
- **[Claude Desktop (Cowork)](docs/mcp/CLAUDE_DESKTOP.md)** — Settings → Integrations → add the URL of your HTTP server. Remote only; the local `claude_desktop_config.json` does not work for remote servers.
- **[Claude Cowork (team plan)](docs/mcp/CLAUDE_COWORK.md)** — org Owner adds the connector under Organization Settings → Connectors.
- **[Perplexity Computer](docs/mcp/PERPLEXITY.md)** — `gbrain connect https://your-host/mcp --agent perplexity --oauth --register` mints a least-privilege OAuth client and prints the Issuer/Client ID/Secret to paste into Settings → Connectors (OAuth is the right path for a cloud connector; a bearer token also works for local use). Pro subscription required.
@@ -2041,7 +2059,7 @@ the page PK, soft-delete-filtered, source-safe) and completes in seconds.
## Docs
- [`docs/INSTALL.md`](docs/INSTALL.md) — every install path, end to end
- [`docs/guides/bootstrap.md`](docs/guides/bootstrap.md) — the persistent-personal-agent bootstrap contract (interview, identity files, hooks, private repo, security posture, uninstall)
- [`docs/guides/bootstrap.md`](docs/guides/bootstrap.md) — the persistent-personal-agent bootstrap contract (interview, identity files, hooks, private repo, security posture, uninstall), plus local harness mode (`gbrain bootstrap harness`) for wiring framework-spawned Claude Code/Codex sessions to a running serve
- [`docs/what-schemas-unlock.md`](docs/what-schemas-unlock.md) — why schemas matter: 7 killer use cases, the structural argument for typed page kinds, the agent-co-curates pattern (v0.40.7.0)
- [`docs/schema-author-tutorial.md`](docs/schema-author-tutorial.md) — 5-minute walkthrough: fork the bundled pack, add a custom type, backfill existing pages, prove the wiring via `gbrain whoknows`
- [`docs/architecture/`](docs/architecture/) — system design, topologies, retrieval theory
@@ -3964,9 +3982,11 @@ Source: https://raw.githubusercontent.com/garrytan/gbrain/master/docs/mcp/DEPLOY
> PKCE, refresh rotation, optional DCR), an embedded React admin dashboard at
> `/admin`, scoped operations, and a live SSE activity feed. Legacy bearer
> tokens still work — `verifyAccessToken` falls back to the `access_tokens`
> table and grandfathers tokens to `read+write+admin`. Both the legacy fallback
> and the OAuth tables work on PGLite and Postgres (both engine schemas carry
> `access_tokens`). See [SECURITY.md](../../SECURITY.md) for env vars and
> table; tokens with no `scopes` grant are grandfathered to `read+write+admin`,
> while tokens minted with `gbrain auth create --scopes …` (or by
> `gbrain bootstrap harness`) are honored at exactly their granted scopes.
> Both the legacy fallback and the OAuth tables work on PGLite and Postgres
> (both engine schemas carry `access_tokens`). See [SECURITY.md](../../SECURITY.md) for env vars and
> tunable defaults.
Access your brain from any device, any AI client. GBrain ships two transports:
@@ -3984,9 +4004,10 @@ gbrain serve --surface verbs # just the 7 memory verbs (quickstart surface)
Works with Claude Code, Cursor, Windsurf, and any MCP client that supports stdio.
No server, no tunnel, no token needed. Works on both PGLite and Postgres engines.
`--surface verbs` exposes exactly the five-verb memory protocol (`recall`,
`remember`, `entity`, `synthesize`, `forget` —
`--surface verbs` exposes exactly the seven-verb memory protocol (`recall`,
`remember`, `entity`, `synthesize`, `forget`, `context_pack`, `delta`
[MEMORY_VERBS v1](../protocol/MEMORY_VERBS_v1.md)) instead of the full catalog;
`--surface starter` sits between (~26 ops: the verbs plus the daily-driver set);
omit the flag (default `full`) for every operation.
### Remote over OAuth 2.1 (recommended)
@@ -4027,8 +4048,9 @@ This requires:
2. A public tunnel (ngrok, Tailscale, or cloud host)
3. A bearer token created via `gbrain auth create <name>`
Existing bearer tokens are grandfathered as `read+write+admin` scopes on the
OAuth-capable HTTP server, so no migration is required.
Existing bearer tokens (no `scopes` grant) are grandfathered as
`read+write+admin` on the OAuth-capable HTTP server, so no migration is
required; `gbrain auth create --scopes read,write` mints narrowed tokens.
## OAuth 2.1 Setup
@@ -4123,6 +4145,22 @@ await oauthProvider.registerClientManual(
For self-service client registration (Dynamic Client Registration, RFC 7591),
start the server with `--enable-dcr`. DCR is off by default.
DCR requests may include an optional `token_ttl_seconds` field (integer,
seconds) to request a per-client access-token lifetime. The server clamps the
request into an admin-configured window — never rejects over it — persists the
effective value as the client's TTL override, and echoes it back as
`token_ttl_seconds` in the registration response. Subsequent `/token` responses
for that client carry the matching `expires_in`. Clients that omit the field
keep the server default (`--token-ttl`). The window defaults fail-closed: min
300 seconds, max bounded by your `--token-ttl` — a self-registering client
cannot request a longer-lived token than the server default unless you
explicitly widen the window:
```bash
gbrain config set oauth.dcr_ttl_min_seconds 600
gbrain config set oauth.dcr_ttl_max_seconds 86400
```
### 3. Expose the server
**Bind explicitly.** `gbrain serve --http` defaults to `127.0.0.1`.
@@ -4166,7 +4204,8 @@ Write ops can additionally be fenced per client with `--bound-slug-prefixes`
## Legacy Bearer Token Setup
Bearer tokens are the simple path when you don't need per-client scoping.
They grandfather to `read+write+admin` scopes on the HTTP server.
Without a `--scopes` grant they grandfather to `read+write+admin` on the
HTTP server; pass `--scopes read,write` at creation to narrow one.
### 1. Set up the tunnel
@@ -4192,8 +4231,11 @@ gbrain auth list
gbrain auth revoke "claude-desktop"
```
Tokens are per-client. Create one for each device/app. Revoke individually
if compromised. Tokens are stored SHA-256 hashed in your database.
Tokens are per-client. Create one for each device/app. Names are not
unique: `gbrain auth revoke "<name>"` revokes EVERY active token carrying
that name — use `gbrain auth list` (shows each token's id and scopes) and
`gbrain auth revoke --id <uuid>` to revoke exactly one. Tokens are stored
SHA-256 hashed in your database.
### 3. Connect your AI client
@@ -4381,10 +4423,28 @@ each client.
**Surface modes:** `--surface verbs` exposes EXACTLY the seven verbs —
advertised list AND dispatch are filtered fail-closed (a hidden op returns
`unknown_tool` even when called by name). `--surface full` (the default)
exposes every operation, verbs included. Why default full: verbs is for
agents and quickstarts; full preserves existing advanced tooling. Persist a
default with `gbrain config set mcp_surface verbs`.
`unknown_tool` even when called by name). `--surface starter` exposes the
~26-op daily-driver set (`STARTER_OPS` in `src/mcp/surface.ts`): the seven
verbs plus the daily brain-tool slice, the agent lane, `whoami`, and the
`request_tools` discovery meta-op (re-derivable from production usage via
`scripts/derive-starter-ops.ts`). Monotonic by construction: verbs ⊆ starter ⊆ full
(pinned by test) — starter extends the ladder ABOVE verbs and never changes
verb semantics. `--surface full` (the default) exposes every operation,
verbs included. Why default full: verbs/starter are for agents and
quickstarts; full preserves existing advanced tooling. Persist a default
with `gbrain config set mcp_surface verbs`.
**Ceiling semantics (OAuth HTTP transport):** the server-resolved surface
is a CEILING, not the final answer. Each request resolves
`min(ceiling, client row surface ?? mcp.default_surface_dcr ?? ceiling)` —
so a verbs-pinned server always serves verbs regardless of client rows,
while a full server can narrow individual clients
(`gbrain auth rescope-client <id> --surface starter`) or let them narrow
themselves via `request_tools` (never past the ceiling; an operator-set
row is locked against self-service). Recomputed per request — rescopes
take effect on the client's next request; clients should re-issue
tools/list after a surface change. stdio and the legacy bearer transport
have no per-client row: they serve the server-resolved surface directly.
## The verbs
@@ -4493,6 +4553,36 @@ output_tokens, usd_estimate}, protocol_version }`.
- No LLM configured ⇒ the protocol error `unavailable` with a fix — never a
fake answer.
#### synthesize compose status (v0.45.x, additive)
Every response additionally carries four ADDITIVE-FOREVER fields (absent on
pre-v0.45.x servers; a server that omits them still certifies):
- `synthesis_status` — how `answer` was produced: `ok` (LLM synthesis) or
`extractive_fallback` (the LLM compose step failed but retrieval succeeded —
`answer` is an extractive digest quoting ONLY retrieved pages, `sources`
cite the digested pages). The remaining enum values (`empty_answer`,
`not_json`, `no_llm`, `model_unusable`, `llm_error`) name compose-failure
states a non-verb `think` surface may report; the verb converts them to the
fallback or a typed error and never emits them itself.
- `pages_gathered` / `takes_gathered` — retrieval counts behind the answer.
- `warnings` — machine-stable pipeline warning codes (e.g.
`LLM_OUTPUT_NOT_JSON`, `SYNTHESIS_EMPTY_ANSWER`, `LLM_CALL_FAILED: <class>`
where `<class>` is one of the closed set `timeout` | `rate_limited` |
`network` | `provider_error` — raw provider detail never rides the wire,
`MODEL_NOT_USABLE:<reason>`).
Precedence (frozen): compose failure + NON-EMPTY gather ⇒
`extractive_fallback` — the digest is composed exclusively from gathered
pages, never fabricated. Compose failure + EMPTY gather ⇒ the protocol error
`unavailable` with message `retrieved 0 pages; compose failed: <warning-code>`
(an empty gather NEVER produces an answer). Provider/transport failures at
call time (429 / timeout / 5xx / network) are caught into `llm_error` and
follow the same precedence. No LLM configured stays the `unavailable`
configure-and-retry error regardless of gather — an extractive digest would
mask the misconfiguration forever. Refusals parse as `not_json` (coarse on
purpose, no dedicated status).
### forget(id, reason?) — write
Expire a fact by its opaque string id (from `remember` or
+2 -1
View File
@@ -1,7 +1,7 @@
{
"id": "gbrain-context-engine",
"name": "gbrain",
"version": "0.45.7.0",
"version": "0.45.14.0",
"description": "Personal knowledge brain with Postgres + pgvector hybrid search",
"family": "bundle-plugin",
"configSchema": {
@@ -47,6 +47,7 @@
"skills/capture",
"skills/citation-fixer",
"skills/citation-graph-ingest",
"skills/cold-start",
"skills/company-brainify",
"skills/concept-synthesis",
"skills/context-audit",
+2 -1
View File
@@ -79,6 +79,7 @@
"check:privacy": "bash scripts/check-privacy.sh",
"check:proposal-pii": "bash scripts/check-proposal-pii.sh",
"check:eval-glossary": "bash scripts/check-eval-glossary-fresh.sh",
"check:tool-catalog": "bash scripts/check-tool-catalog-fresh.sh",
"check:skills-manifest": "bash scripts/check-skills-manifest-fresh.sh",
"check:test-names": "bash scripts/check-test-real-names.sh",
"check:progress": "bash scripts/check-progress-to-stdout.sh",
@@ -154,7 +155,7 @@
"bun": ">=1.3.10"
},
"license": "MIT",
"version": "0.45.7.0",
"version": "0.45.14.0",
"overrides": {
"@hono/node-server": "^2.0.5",
"fast-uri": "^3.1.5",
@@ -49,6 +49,8 @@ ALLOWED=(
"src/commands/enrich.ts" # local CLI tool; calls put_page handler with remote=false, not network-exposed
"src/commands/book-mirror.ts" # local CLI tool; not network-exposed
"src/commands/tools-json.ts" # gbrain --tools-json introspection; full op list IS the purpose
"src/mcp/publish-gates.ts" # reads op.publishGateKey/name only to compute gate-DISABLED sets; never lists/exposes ops
"src/mcp/tool-catalog.ts" # docs/TOOL_CATALOG.md renderer; filters !op.localOnly at the boundary; never a transport surface
"src/commands/serve-http.ts" # MUST APPLY .filter(op => !op.localOnly) — verified by grep below
)
+6 -4
View File
@@ -32,7 +32,12 @@ cd "$ROOT"
BANNED_NAMES=(
'Diana' # Diana Hu, named in CLAUDE.md privacy example
'Wintermute' # private OpenClaw fork name (CLAUDE.md rule)
'Hermes' # downstream agent fork name
# 'Hermes' removed (hermes-harness wave): here it names NousResearch/hermes-agent,
# a PUBLIC platform gbrain documents (README hero, INSTALL_FOR_AGENTS.md) and now
# tests against (claw-test hermes runner, install door e2e).
# test/readme-hero-anchors.test.ts REQUIRES the README to mention it. The original
# scrub targeted conflating the public agent with PRIVATE deployment names — those
# (Wintermute, and any future private fork names) remain banned above/below.
'Technium' # real GP handle
'McGrew' # ex-OpenAI exec
'YC Labs' # internal team name
@@ -57,9 +62,6 @@ ALLOWLIST=(
"test/recency-decay.test.ts:Wintermute" # regression-prevention test asserting wintermute is absent (structural)
"test/scripts/check-proposal-pii.test.ts:Wintermute" # privacy-guard test asserting docs/proposals/ rejects wintermute (structural; same meta-rule exception as check-privacy.sh)
"test/scripts/check-proposal-pii.test.ts:WINTERMUTE" # case-insensitive sentinel literal for the same privacy-guard test
"test/serve-stdio-lifecycle.test.ts:Hermes" # comment naming a downstream-agent scenario — pre-existing, low signal
"test/extract.test.ts:Hermes" # markdown-link extraction test fixture — pre-existing, ambiguous (Greek god vs fork)
"test/readme-hero-anchors.test.ts:Hermes" # v0.36.0.0 D9 anchor test — asserts README mentions Hermes as a credit
"test/readme-hero-anchors.test.ts:OpenClaw" # v0.36.0.0 D9 anchor test — asserts README mentions OpenClaw as a credit
# v0.36.0.0: skillpack-harvest privacy linter tests structurally
# require the literal "Wintermute" to verify the linter catches it.
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
# E6 — CI guard for docs/TOOL_CATALOG.md freshness.
#
# Mirrors scripts/check-eval-glossary-fresh.sh: regenerate the doc into a tmp
# file, diff against the committed version, fail the build if they drift.
# The renderer is config-independent + deterministic (no timestamps), so a
# diff means someone changed operations/surface metadata without running the
# generator.
#
# Run: bash scripts/check-tool-catalog-fresh.sh
# Wired into `bun run verify` via package.json `check:tool-catalog`.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
COMMITTED="$REPO_ROOT/docs/TOOL_CATALOG.md"
TMP="$(mktemp)"
trap 'rm -f "$TMP"' EXIT
if [ ! -f "$COMMITTED" ]; then
echo "ERROR: $COMMITTED not found." >&2
echo "Run: bun run scripts/generate-tool-catalog.ts" >&2
exit 1
fi
cd "$REPO_ROOT"
bun -e "import { renderToolCatalogMarkdown } from './src/mcp/tool-catalog.ts'; process.stdout.write(renderToolCatalogMarkdown() + '\n');" > "$TMP"
if ! diff -q "$COMMITTED" "$TMP" >/dev/null 2>&1; then
echo "ERROR: docs/TOOL_CATALOG.md is stale." >&2
echo "" >&2
echo "Diff between committed and freshly-generated:" >&2
echo "" >&2
diff -u "$COMMITTED" "$TMP" >&2 || true
echo "" >&2
echo "To regenerate: bun run scripts/generate-tool-catalog.ts" >&2
exit 1
fi
echo "✓ docs/TOOL_CATALOG.md is fresh"
+172
View File
@@ -0,0 +1,172 @@
#!/usr/bin/env bun
/**
* derive-starter-ops.ts propose the STARTER_OPS daily-driver slice from
* production `mcp_request_log` usage (amendment 23 + D12).
*
* Run: bun run scripts/derive-starter-ops.ts [--days N] [--target N]
*
* Reads the active brain (loadConfig engine) through the shared usage
* reader (src/core/mcp-usage.ts the same hygiene rules as the E4 CLI and
* the E3 advisor drift check: JSON-RPC method rows and 'surface_change'
* audit rows dropped, the legacy 'tools/call:<name>' prefix stripped),
* excludes automation-shaped clients (D12: >90% context_pack/delta boundary
* calls), and ranks ops by CLIENT COUNT the union of per-client
* DISTINCT-op sets, never raw call volume, so one chatty client cannot
* define the starter set.
*
* PRINTS a proposed block for a human to paste into src/mcp/surface.ts
* (replacing FALLBACK_DAILY_OPS). It NEVER edits files. The always-included
* slices (VERB_NAMES spread + whoami + request_tools + the agent lane) are
* composed in surface.ts itself and excluded from the proposal.
*
* Caveats printed with the proposal:
* - HTTP clients only (stdio never writes mcp_request_log).
* - BRAIN_TOOL_ALLOWLIST cross-check (D12): allowlist members missing from
* the derived set are listed dropping one strands subagent parity.
* - test/mcp-surface.test.ts pins membership + monotonicity
* (verbs starter full); run it after pasting.
*/
import { loadConfig, toEngineConfig } from '../src/core/config.ts';
import { createEngine } from '../src/core/engine-factory.ts';
import { readClientOpUsage, MCP_USAGE_DEFAULT_WINDOW_DAYS } from '../src/core/mcp-usage.ts';
import { operations } from '../src/core/operations.ts';
import { ALWAYS_INCLUDED_STARTER_OPS } from '../src/mcp/surface.ts';
import { BRAIN_TOOL_ALLOWLIST } from '../src/core/minions/tools/brain-allowlist.ts';
/** Ops surface.ts always includes regardless of derivation (shared constant). */
const ALWAYS_INCLUDED = ALWAYS_INCLUDED_STARTER_OPS;
/** Target total STARTER_OPS size (the "~20-op daily-driver set"). */
const DEFAULT_TARGET_SIZE = 20;
function parseArgs(argv: string[]): { days: number; target: number } {
let days = MCP_USAGE_DEFAULT_WINDOW_DAYS;
let target = DEFAULT_TARGET_SIZE;
for (let i = 0; i < argv.length; i++) {
if (argv[i] === '--days') {
days = Number(argv[++i]);
if (!Number.isInteger(days) || days < 1 || days > 3650) {
console.error('--days must be an integer between 1 and 3650');
process.exit(1);
}
} else if (argv[i] === '--target') {
target = Number(argv[++i]);
if (!Number.isInteger(target) || target < ALWAYS_INCLUDED.size) {
console.error(`--target must be an integer >= ${ALWAYS_INCLUDED.size} (the always-included slice)`);
process.exit(1);
}
} else {
console.error(`Unknown flag: ${argv[i]}`);
console.error('Usage: bun run scripts/derive-starter-ops.ts [--days N] [--target N]');
process.exit(1);
}
}
return { days, target };
}
const { days, target } = parseArgs(process.argv.slice(2));
const config = loadConfig();
if (!config) {
console.error('No GBrain config found. Run `gbrain init` first, or set DATABASE_URL / GBRAIN_DATABASE_URL.');
process.exit(1);
}
const engineConfig = toEngineConfig(config);
const engine = await createEngine(engineConfig);
await engine.connect(engineConfig);
async function run(): Promise<number> {
let usage: Awaited<ReturnType<typeof readClientOpUsage>>;
try {
usage = await readClientOpUsage(engine, { days });
} catch (e) {
// A brain that never served remote MCP may lack the table entirely.
console.error('Could not read mcp_request_log — has this brain ever served remote MCP?');
console.error(` ${e instanceof Error ? e.message : String(e)}`);
return 1;
}
const automation = usage.filter((u) => u.likely_automation);
const real = usage.filter((u) => !u.likely_automation);
const knownOps = new Map(operations.map((o) => [o.name, o]));
// Client-count ranking over per-client DISTINCT-op sets (D12).
const clientCount = new Map<string, number>();
const callCount = new Map<string, number>();
const skippedUnknown = new Set<string>();
for (const u of real) {
for (const op of u.distinct_ops) {
const known = knownOps.get(op);
if (!known) {
skippedUnknown.add(op); // attempted-but-nonexistent names from error rows
continue;
}
if (known.localOnly) continue; // never proposable for a network surface
clientCount.set(op, (clientCount.get(op) ?? 0) + 1);
callCount.set(op, (callCount.get(op) ?? 0) + (u.ops[op] ?? 0));
}
}
const ranked = [...clientCount.entries()]
.sort((a, b) => b[1] - a[1] || (callCount.get(b[0]) ?? 0) - (callCount.get(a[0]) ?? 0) || (a[0] < b[0] ? -1 : 1))
.map(([op]) => op);
const derivedSlots = Math.max(0, target - ALWAYS_INCLUDED.size);
const proposal = ranked.filter((op) => !ALWAYS_INCLUDED.has(op)).slice(0, derivedSlots);
const today = new Date().toISOString().slice(0, 10);
const lines: string[] = [];
lines.push('/**');
lines.push(` * STARTER_OPS daily-driver slice — derived from production mcp_request_log.`);
lines.push(` * Provenance: window=${days}d, generated=${today}, script=scripts/derive-starter-ops.ts,`);
lines.push(` * clients=${real.length} (excluded ${automation.length} automation-shaped: >90% context_pack/delta).`);
lines.push(` * HTTP clients only — stdio does not write mcp_request_log.`);
lines.push(` * NOTE: VERB_NAMES + whoami + request_tools + the agent lane (submit_agent,`);
lines.push(` * get_agent_job) are ALWAYS included by surface.ts and are not listed here.`);
lines.push(' */');
lines.push('const DERIVED_DAILY_OPS: readonly string[] = [');
for (const op of proposal) {
lines.push(` '${op}', // ${clientCount.get(op)} client${clientCount.get(op) === 1 ? '' : 's'}, ${callCount.get(op)} calls/${days}d`);
}
lines.push('];');
console.log(`# derive-starter-ops — ${days}d window, ${usage.length} clients seen (${automation.length} automation-shaped excluded)\n`);
if (real.length === 0) {
console.log('No non-automation HTTP client usage in the window. The FOV-6b fallback');
console.log('(BRAIN_TOOL_ALLOWLIST agent lane) in src/mcp/surface.ts remains the right set.');
return 0;
}
console.log('Ranked ops (client count desc, then call count):');
for (const op of ranked) {
const marks: string[] = [];
if (ALWAYS_INCLUDED.has(op)) marks.push('always-included');
if (proposal.includes(op)) marks.push('PROPOSED');
console.log(` ${op.padEnd(28)} clients=${clientCount.get(op)} calls=${callCount.get(op)}${marks.length ? ' [' + marks.join(', ') + ']' : ''}`);
}
if (skippedUnknown.size > 0) {
console.log(`\nSkipped ${skippedUnknown.size} logged name(s) not in the operations catalog (failed-call attempts).`);
}
const allowlistMissing = [...BRAIN_TOOL_ALLOWLIST].filter(
(op: string) => !proposal.includes(op) && !ALWAYS_INCLUDED.has(op),
);
if (allowlistMissing.length > 0) {
console.log(`\nWARNING (D12 cross-check): BRAIN_TOOL_ALLOWLIST members absent from the proposal:`);
console.log(` ${allowlistMissing.join(', ')}`);
console.log(' Dropping these breaks subagent/starter parity — include them unless deliberate.');
}
console.log('\nProposed block — paste into src/mcp/surface.ts (replacing FALLBACK_DAILY_OPS),');
console.log('then run: bun test --timeout=60000 test/mcp-surface.test.ts && bun run scripts/generate-tool-catalog.ts\n');
console.log(lines.join('\n'));
return 0;
}
let exitCode = 1;
try {
exitCode = await run();
} finally {
await engine.disconnect();
}
process.exit(exitCode);
+723
View File
@@ -0,0 +1,723 @@
/**
* dx-explore drive the REAL fresh-user experience under a PTY and record it.
*
* The e2e door tests (test/e2e/bootstrap-real-{claude,codex}.serial.test.ts)
* prove the install WORKS headlessly. This script captures what installing
* FEELS like: every picker, prompt, spinner, silence window, and line of copy
* a fresh user sees, as timestamped transcripts ready for a
* Don't-Make-Me-Think DX audit. It is a developer instrument, not a test
* transcripts land in .context/dx-runs/ (gitignored) and nothing asserts.
*
* Scenarios (all hermetic temp HOME/GBRAIN_HOME/CLAUDE_CONFIG_DIR/CODEX_HOME;
* the operator's real config is never WRITTEN. Two narrow reads exist for
* auth: codex-install copies ~/.codex/auth.json into the temp CODEX_HOME, and
* the claude seed records the API key's last 20 chars both copies are
* scrubbed at cleanup even under --keep, so no credential material outlives
* the run):
*
* help First-touch comprehension surfaces: bare `gbrain`,
* `gbrain --help`, `gbrain init --help`, `gbrain bootstrap
* --help`, `gbrain bootstrap` bare. Cheap, no keys.
* init Interactive `gbrain init` (keyless) with a naive-user
* autopilot: wait for each screen to settle, snapshot it,
* press Enter (accept the default), repeat. What a user who
* "just hits Enter" experiences, with stall timing.
* claude-install REAL interactive `claude` in a fresh empty workspace,
* driven by the README paste block pointed at THIS repo's
* BOOTSTRAP_FOR_AGENTS.md, with a scripted persona appendix
* so the interview completes unattended. Pays real API cost;
* takes 10-25 min. Run in background and watch session/screen.txt.
* codex-install Same for REAL `codex` (interactive TUI).
* drive -- <cmd> Manual mode: spawn ANY command under the PTY and steer it
* across separate shell calls via a file control channel:
* watch: cat <dir>/session/screen.txt
* type: echo '{"line":"hello"}' >> <dir>/session/input.jsonl
* keys: echo '{"key":"Down"}' >> <dir>/session/input.jsonl
* note: echo '{"note":"picker confuses me"}' >> ...
* stop: echo '{"stop":true}' >> ...
* {"line": ...} sends text + Enter; {"send": ...} sends raw
* bytes (mind that zsh `echo` mangles \r prefer "line").
* Launch as a background task; this is how an agent in
* Conductor explores a live TUI across tool calls.
*
* Usage:
* bun run scripts/dx-explore.ts help
* bun run scripts/dx-explore.ts init
* bun run scripts/dx-explore.ts claude-install
* bun run scripts/dx-explore.ts codex-install
* bun run scripts/dx-explore.ts drive [--no-hermetic-home] -- gbrain init
* Options: --dir <out> transcript dir (default .context/dx-runs/<scenario>-<ts>)
* --gbrain <bin> use an existing gbrain binary (default: compile+cache)
* --rebuild force recompile of the cached binary
* --keep keep hermetic temp homes for forensics
*
* Output bundle per scenario dir: meta.json, raw.txt, visible.txt,
* frames.jsonl, stalls.md, events.jsonl (inputs/notes timeline), steps.md
* (autopilot screen-by-screen), session/ (live: screen.txt, status.json).
*
* Progress prints to stderr; the transcript dir path is the only stdout line
* (pipe-friendly), matching the repo's progress discipline.
*/
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import {
launchTty,
saveTranscript,
seedClaudeTuiConfig,
parseDriveCommand,
type TtySession,
} from '../test/helpers/tty-harness.ts';
const REPO_ROOT = path.resolve(import.meta.dir, '..');
/** Screen patterns that mean the paste-in install reached a passing verify
* ONE list shared by the claude-install and codex-install scenarios so the
* two can't drift when the bootstrap's success copy changes. */
const VERIFY_SUCCESS_PATTERNS: Array<RegExp | string> = [
/bootstrap verify.*exit(?:ed|s)? 0/i,
/verify\b.*\b(passed|0\b)/i,
/All checks passed/i,
];
// Same synthetic persona the door tests use — the interview can complete
// unattended and nothing real about the operator ever enters a transcript.
const PERSONA = {
AGENT_NAME: 'Lighthouse',
PRINCIPAL_NAME: 'Pat Example',
AGENT_PURPOSE: 'Maintain the research corpus and draft the weekly memo without re-briefing.',
AGENT_TOP_JOBS: 'corpus upkeep; weekly memo; meeting prep',
PRINCIPAL_CONTEXT: 'Runs a small research group; builds internal tooling; values signal over noise.',
VOICE_REGISTER: 'Direct: three options, the second one wins.',
};
function log(msg: string): void {
process.stderr.write(`[dx-explore] ${msg}\n`);
}
function nowStamp(): string {
return new Date().toISOString().replace(/[:.]/g, '-').replace('T', '-').slice(0, 19);
}
// ── arg parsing ──────────────────────────────────────────────────────────────
interface CliArgs {
scenario: string;
dir?: string;
gbrainBin?: string;
rebuild: boolean;
keep: boolean;
/** Strip provider API keys from the child env the TRUE keyless posture.
* Without this, a Conductor session's ANTHROPIC_API_KEY leaks into the
* hermetic run and the keyless first-touch path is never exercised. */
keyless: boolean;
hermeticHome: boolean;
driveArgv: string[];
}
/** Provider keys the hermetic base allows through; --keyless drops them. */
const PROVIDER_KEY_NAMES = [
'ANTHROPIC_API_KEY',
'ANTHROPIC_AUTH_TOKEN',
'OPENAI_API_KEY',
'GSTACK_ANTHROPIC_API_KEY',
'GSTACK_OPENAI_API_KEY',
];
function parseArgs(argv: string[]): CliArgs {
const out: CliArgs = {
scenario: '',
rebuild: false,
keep: false,
keyless: false,
hermeticHome: true,
driveArgv: [],
};
let i = 0;
const sep = argv.indexOf('--');
const own = sep >= 0 ? argv.slice(0, sep) : argv;
out.driveArgv = sep >= 0 ? argv.slice(sep + 1) : [];
while (i < own.length) {
const a = own[i]!;
if (a === '--dir') out.dir = own[++i];
else if (a === '--gbrain') out.gbrainBin = own[++i];
else if (a === '--rebuild') out.rebuild = true;
else if (a === '--keep') out.keep = true;
else if (a === '--keyless') out.keyless = true;
else if (a === '--no-hermetic-home') out.hermeticHome = false;
else if (!out.scenario && !a.startsWith('--')) out.scenario = a;
else {
log(`unknown argument: ${a}`);
process.exit(2);
}
i++;
}
return out;
}
// ── compiled gbrain binary (what a real user runs) ───────────────────────────
/** Compile (or reuse) a standalone gbrain binary. `bun run src/cli.ts` adds a
* multi-second transpile stall to EVERY invocation that a real install never
* has a compiled binary keeps the timing honest. Cached under
* .context/dx-runs/bin/ keyed on nothing (use --rebuild after code changes). */
function ensureGbrainBinary(explicit: string | undefined, rebuild: boolean): string {
if (explicit) {
fs.accessSync(explicit, fs.constants.X_OK);
return path.resolve(explicit);
}
const binDir = path.join(REPO_ROOT, '.context', 'dx-runs', 'bin');
const binPath = path.join(binDir, 'gbrain');
if (!rebuild && fs.existsSync(binPath)) {
log(`reusing compiled gbrain at ${binPath} (--rebuild to refresh)`);
return binPath;
}
fs.mkdirSync(binDir, { recursive: true });
log('compiling gbrain (bun build --compile)…');
const res = spawnSync('bun', ['build', '--compile', '--outfile', binPath, 'src/cli.ts'], {
cwd: REPO_ROOT,
encoding: 'utf8',
timeout: 300_000,
maxBuffer: 64 * 1024 * 1024,
});
if (res.status !== 0 || !fs.existsSync(binPath)) {
throw new Error(`bun build --compile failed (exit ${res.status}):\n${(res.stderr ?? '').slice(-2000)}`);
}
log(`compiled ${binPath}`);
return binPath;
}
// ── scenario plumbing ────────────────────────────────────────────────────────
interface ScenarioCtx {
outDir: string;
gbrainBin: string;
keep: boolean;
/** temp dirs to remove on completion unless --keep */
cleanups: string[];
/** Files carrying credential material (copied auth.json, seeded key
* suffixes). ALWAYS deleted at cleanup --keep keeps transcripts and
* hermetic dirs for forensics, never credentials. */
secretPaths: string[];
events: Array<{ tMs: number; kind: 'input' | 'note' | 'screen'; data: string }>;
t0: number;
}
function newCtx(args: CliArgs, needsGbrain: boolean): ScenarioCtx {
const outDir = path.resolve(
args.dir ?? path.join(REPO_ROOT, '.context', 'dx-runs', `${args.scenario}-${nowStamp()}`),
);
fs.mkdirSync(outDir, { recursive: true });
const ctx: ScenarioCtx = {
outDir,
gbrainBin: needsGbrain ? ensureGbrainBinary(args.gbrainBin, args.rebuild) : '',
keep: args.keep,
cleanups: [],
secretPaths: [],
events: [],
t0: Date.now(),
};
installSignalScrub(ctx);
return ctx;
}
function tmp(ctx: ScenarioCtx, prefix: string): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
ctx.cleanups.push(dir);
return dir;
}
function event(ctx: ScenarioCtx, kind: 'input' | 'note' | 'screen', data: string): void {
ctx.events.push({ tMs: Date.now() - ctx.t0, kind, data });
}
/** Delete every credential copy. Idempotent; safe to call from a signal
* handler AND from finishCtx (a second call is a no-op). This is the
* "no credential outlives the run" guarantee it must run even when a
* 10-25min install is Ctrl-C'd (finally does NOT run on SIGINT default). */
function scrubSecrets(ctx: ScenarioCtx): void {
for (const p of ctx.secretPaths) {
try {
fs.rmSync(p, { force: true });
} catch {
/* best-effort */
}
}
}
/** Wire SIGINT/SIGTERM so an interrupted run still scrubs credentials before
* the process dies. Registered once per scenario ctx. */
function installSignalScrub(ctx: ScenarioCtx): void {
const handler = (sig: NodeJS.Signals) => {
scrubSecrets(ctx);
process.stderr.write(`\n[dx-explore] ${sig}: scrubbed credential copies, exiting.\n`);
process.exit(130);
};
process.once('SIGINT', handler);
process.once('SIGTERM', handler);
}
function finishCtx(ctx: ScenarioCtx): void {
// Scrub credentials FIRST — before any other I/O that could throw (an
// events.jsonl write failure must not strand auth files).
scrubSecrets(ctx);
fs.writeFileSync(
path.join(ctx.outDir, 'events.jsonl'),
ctx.events.map((e) => JSON.stringify(e)).join('\n') + (ctx.events.length ? '\n' : ''),
);
if (ctx.keep && ctx.secretPaths.length > 0) {
log(`--keep: retained hermetic dirs, but scrubbed ${ctx.secretPaths.length} credential file(s)`);
}
if (!ctx.keep) {
for (const d of ctx.cleanups) {
try {
fs.rmSync(d, { recursive: true, force: true });
} catch {
/* best-effort */
}
}
} else {
fs.writeFileSync(
path.join(ctx.outDir, 'hermetic-dirs.json'),
JSON.stringify(ctx.cleanups, null, 2),
);
}
// The one stdout line: where the transcript landed.
console.log(ctx.outDir);
}
/** Live session mirror so a watcher (or a Conductor agent) can follow along:
* session/screen.txt (latest visible tail) + session/status.json. */
function mirrorSession(dir: string, session: TtySession): () => void {
const sessDir = path.join(dir, 'session');
fs.mkdirSync(sessDir, { recursive: true });
const timer = setInterval(() => {
try {
fs.writeFileSync(path.join(sessDir, 'screen.txt'), session.visible().slice(-8000));
fs.writeFileSync(
path.join(sessDir, 'status.json'),
JSON.stringify(
{
running: !session.exited(),
exitCode: session.exitCode(),
elapsedMs: Date.now() - session.startedAtMs,
frames: session.frames().length,
},
null,
2,
),
);
} catch {
/* best-effort */
}
}, 500);
return () => clearInterval(timer);
}
function saveSession(ctx: ScenarioCtx, name: string, session: TtySession, extraMeta: Record<string, unknown> = {}): void {
const dir = name ? path.join(ctx.outDir, name) : ctx.outDir;
saveTranscript(dir, {
frames: session.frames(),
raw: session.raw(),
meta: {
scenario: name || path.basename(ctx.outDir),
argv: session.argv,
startedAtIso: new Date(session.startedAtMs).toISOString(),
exitCode: session.exitCode(),
durationMs: Date.now() - session.startedAtMs,
...extraMeta,
},
});
}
// ── scenario: help ───────────────────────────────────────────────────────────
async function scenarioHelp(ctx: ScenarioCtx, args: CliArgs): Promise<void> {
const home = tmp(ctx, 'gb-dx-home-');
const ws = tmp(ctx, 'gb-dx-ws-');
const dropEnv = args.keyless ? PROVIDER_KEY_NAMES : undefined;
const surfaces: Array<{ name: string; argv: string[] }> = [
{ name: 'step-01-bare', argv: [ctx.gbrainBin] },
{ name: 'step-02-help', argv: [ctx.gbrainBin, '--help'] },
{ name: 'step-03-init-help', argv: [ctx.gbrainBin, 'init', '--help'] },
{ name: 'step-04-bootstrap-help', argv: [ctx.gbrainBin, 'bootstrap', '--help'] },
{ name: 'step-05-bootstrap-bare', argv: [ctx.gbrainBin, 'bootstrap'] },
{ name: 'step-06-status-fresh', argv: [ctx.gbrainBin, 'status'] },
];
for (const s of surfaces) {
log(`running ${s.name}: ${s.argv.join(' ')}`);
const session = launchTty(s.argv, {
cwd: ws,
env: { HOME: home, GBRAIN_HOME: home },
dropEnv,
timeoutMs: 120_000,
});
await session.waitForExit(110_000);
await session.close();
saveSession(ctx, s.name, session);
}
}
// ── scenario: init (naive-user autopilot) ────────────────────────────────────
async function scenarioInit(ctx: ScenarioCtx, args: CliArgs): Promise<void> {
const home = tmp(ctx, 'gb-dx-home-');
const ws = tmp(ctx, 'gb-dx-ws-');
log(
`interactive \`gbrain init\` (${args.keyless ? 'TRUE keyless — provider keys stripped' : 'ambient keys allowed'}), ` +
'naive-user autopilot: Enter accepts every default',
);
const session = launchTty([ctx.gbrainBin, 'init'], {
cwd: ws,
env: { HOME: home, GBRAIN_HOME: home },
dropEnv: args.keyless ? PROVIDER_KEY_NAMES : undefined,
timeoutMs: 600_000,
});
const stopMirror = mirrorSession(ctx.outDir, session);
const steps: string[] = [];
let lastMarkPos = 0;
const MAX_STEPS = 15;
try {
for (let step = 1; step <= MAX_STEPS && !session.exited(); step++) {
const settled = await session.waitForQuiet({ quietMs: 2000, timeoutMs: 180_000 });
const shot = session.visibleSince(lastMarkPos);
lastMarkPos = session.mark();
const tSec = ((Date.now() - session.startedAtMs) / 1000).toFixed(1);
steps.push(
`## Step ${step} (t+${tSec}s${settled ? '' : ', NEVER SETTLED within 180s'})\n\n` +
'```\n' + shot.trim().slice(-3000) + '\n```\n',
);
event(ctx, 'screen', shot.slice(-2000));
if (session.exited()) break;
log(`step ${step}: screen settled at t+${tSec}s — pressing Enter (default)`);
event(ctx, 'input', 'Enter');
session.sendKey('Enter');
await Bun.sleep(300);
}
await session.waitForExit(60_000);
} finally {
stopMirror();
await session.close();
}
fs.writeFileSync(
path.join(ctx.outDir, 'steps.md'),
`# gbrain init — naive-user autopilot (Enter through every prompt)\n\n${steps.join('\n')}`,
);
saveSession(ctx, '', session, { autopilot: 'enter-through-defaults', keyless: args.keyless });
}
// ── scenarios: claude-install / codex-install ────────────────────────────────
/**
* Handle the harness's own first-run chrome dialogs (Claude Code: workspace
* trust, bypass-permissions warning) so an unattended run reaches the input
* prompt. Each handled dialog is recorded as a note the dialogs ARE part of
* the real first-run friction, just not gbrain's copy. Returns once the
* screen has been quiet with no dialog visible, or at the deadline.
*/
async function settlePastBootDialogs(
ctx: ScenarioCtx,
session: TtySession,
opts: { deadlineMs?: number } = {},
): Promise<void> {
const deadline = Date.now() + (opts.deadlineMs ?? 90_000);
const handled = new Set<string>();
while (Date.now() < deadline) {
await session.waitForQuiet({ quietMs: 2000, timeoutMs: 30_000 });
if (session.exited()) return;
const tail = session.visible().slice(-2500);
if (!handled.has('trust') && /trust this ?folder/i.test(tail.replace(/\s+/g, ' '))) {
handled.add('trust');
event(ctx, 'note', 'boot dialog: workspace trust — accepted (option 1)');
session.send('1');
await Bun.sleep(300);
session.sendKey('Enter');
continue;
}
if (!handled.has('bypass') && /Bypass ?Permissions ?mode/i.test(tail.replace(/\s+/g, ''))) {
handled.add('bypass');
event(ctx, 'note', 'boot dialog: bypass-permissions warning — accepted (option 2)');
session.send('2');
await Bun.sleep(300);
session.sendKey('Enter');
continue;
}
// Codex directory-trust dialog: "Do you trust the contents of this
// directory? 1. Yes, continue 2. No, quit".
if (!handled.has('codex-trust') && /trust ?the ?contents ?of ?this ?directory/i.test(tail.replace(/\s+/g, ''))) {
handled.add('codex-trust');
event(ctx, 'note', 'boot dialog: codex directory trust — accepted (option 1)');
session.send('1');
await Bun.sleep(300);
session.sendKey('Enter');
continue;
}
return; // quiet + no dialog = at the input prompt
}
}
/** The README paste block, pointed at THIS repo's runbook, plus a persona
* appendix so the interview completes unattended. The appendix is the ONLY
* deviation from the shipped block flagged in meta so the audit discounts it. */
function installPrompt(): string {
const runbook = path.join(REPO_ROOT, 'BOOTSTRAP_FOR_AGENTS.md');
return (
`Read and follow every step of: ${runbook}\n` +
`Goal: set yourself up as my persistent personal agent in this folder, with gbrain ` +
`as your memory. Interview me before writing any identity file — never invent ` +
`answers. Ask before anything destructive. You are not done until ` +
`\`gbrain bootstrap verify\` exits 0.\n\n` +
`[Unattended-run appendix — I am stepping away; use these interview answers instead ` +
`of asking me, and do not wait for my input: ` +
`agent name: ${PERSONA.AGENT_NAME}; my name: ${PERSONA.PRINCIPAL_NAME}; ` +
`purpose: ${PERSONA.AGENT_PURPOSE}; top jobs: ${PERSONA.AGENT_TOP_JOBS}; ` +
`about me: ${PERSONA.PRINCIPAL_CONTEXT}; voice: ${PERSONA.VOICE_REGISTER}. ` +
`gbrain is already installed and on PATH. If a step needs GitHub auth or an API key ` +
`that is unavailable, take the documented keyless/local fallback and continue.]`
);
}
async function scenarioClaudeInstall(ctx: ScenarioCtx): Promise<void> {
const home = tmp(ctx, 'gb-dx-home-');
const cfg = tmp(ctx, 'gb-dx-ccfg-');
const gbHome = tmp(ctx, 'gb-dx-gbhome-');
const ws = tmp(ctx, 'gb-dx-ws-');
const binDir = tmp(ctx, 'gb-dx-bin-');
fs.copyFileSync(ctx.gbrainBin, path.join(binDir, 'gbrain'));
fs.chmodSync(path.join(binDir, 'gbrain'), 0o755);
seedClaudeTuiConfig(cfg, {
apiKey: process.env.ANTHROPIC_API_KEY ?? process.env.GSTACK_ANTHROPIC_API_KEY,
// realpath: macOS tmpdirs live under /var → /private/var; claude compares
// against the resolved path, so an unresolved seed misses.
trustedDirs: [ws, fs.realpathSync(ws)],
});
// The seed records the key's last 20 chars — credential-adjacent, so it is
// scrubbed at cleanup even with --keep.
ctx.secretPaths.push(path.join(cfg, '.claude.json'));
log('REAL interactive claude running the paste-in bootstrap (10-25 min, real API cost)');
log(`watch live: cat ${path.join(ctx.outDir, 'session', 'screen.txt')}`);
const session = launchTty(
// --dangerously-skip-permissions: v1 measures flow + copy + stalls without
// permission-dialog babysitting. Permission-prompt COUNT is a separate
// drive-mode pass (the dialogs are Claude Code's chrome, not gbrain copy).
['claude', '--dangerously-skip-permissions'],
{
cwd: ws,
env: {
HOME: home,
CLAUDE_CONFIG_DIR: cfg,
GBRAIN_HOME: gbHome,
PATH: `${binDir}:${process.env.PATH ?? ''}`,
},
timeoutMs: 1_800_000,
},
);
const stopMirror = mirrorSession(ctx.outDir, session);
try {
// Get past first-run chrome (trust dialog, bypass warning), then paste.
await settlePastBootDialogs(ctx, session);
event(ctx, 'input', 'paste install prompt');
session.send(installPrompt());
await Bun.sleep(1500);
session.sendKey('Enter');
// Run until verify-success copy or exit or wall clock.
const done = await Promise.race([
session
.waitForAny(VERIFY_SUCCESS_PATTERNS, {
timeoutMs: 1_500_000,
})
.then(() => 'verify-signal')
.catch(() => 'no-signal'),
session.waitForExit(1_500_000).then(() => 'exited'),
]);
event(ctx, 'note', `terminal condition: ${done}`);
// Let trailing output land.
await session.waitForQuiet({ quietMs: 5000, timeoutMs: 60_000 });
} finally {
stopMirror();
await session.close();
}
saveSession(ctx, '', session, {
promptDeviation: 'unattended persona appendix + local runbook path + preinstalled binary',
runbook: 'BOOTSTRAP_FOR_AGENTS.md (local)',
});
}
async function scenarioCodexInstall(ctx: ScenarioCtx): Promise<void> {
const home = tmp(ctx, 'gb-dx-home-');
const gbHome = tmp(ctx, 'gb-dx-gbhome-');
const ws = tmp(ctx, 'gb-dx-ws-');
const binDir = tmp(ctx, 'gb-dx-bin-');
fs.copyFileSync(ctx.gbrainBin, path.join(binDir, 'gbrain'));
fs.chmodSync(path.join(binDir, 'gbrain'), 0o755);
// Hermetic ~/.codex with ONLY the operator's auth (same posture as the
// codex door test). codex refuses untrusted cwds — a git repo satisfies it.
const codexHome = path.join(home, '.codex');
fs.mkdirSync(codexHome, { recursive: true });
const realAuth = path.join(os.homedir(), '.codex', 'auth.json');
if (fs.existsSync(realAuth)) {
const authCopy = path.join(codexHome, 'auth.json');
fs.copyFileSync(realAuth, authCopy);
fs.chmodSync(authCopy, 0o600); // copyFileSync doesn't preserve source mode
ctx.secretPaths.push(authCopy); // scrubbed at cleanup, even with --keep
}
spawnSync('git', ['init', '-q', ws]);
spawnSync('git', ['-C', ws, 'config', 'user.email', 'dx@example.com']);
spawnSync('git', ['-C', ws, 'config', 'user.name', 'DX Explore']);
log('REAL interactive codex running the paste-in bootstrap (10-25 min, real API cost)');
log(`watch live: cat ${path.join(ctx.outDir, 'session', 'screen.txt')}`);
const session = launchTty(
['codex', '--sandbox', 'workspace-write', '--ask-for-approval', 'never'],
{
cwd: ws,
env: {
HOME: home,
CODEX_HOME: codexHome,
GBRAIN_HOME: gbHome,
PATH: `${binDir}:${process.env.PATH ?? ''}`,
},
extraAllow: ['OPENAI_API_KEY', 'CODEX_*'],
timeoutMs: 1_800_000,
},
);
const stopMirror = mirrorSession(ctx.outDir, session);
try {
await settlePastBootDialogs(ctx, session);
event(ctx, 'input', 'paste install prompt');
session.send(installPrompt());
await Bun.sleep(1500);
session.sendKey('Enter');
const done = await Promise.race([
session
.waitForAny(VERIFY_SUCCESS_PATTERNS, {
timeoutMs: 1_500_000,
})
.then(() => 'verify-signal')
.catch(() => 'no-signal'),
session.waitForExit(1_500_000).then(() => 'exited'),
]);
event(ctx, 'note', `terminal condition: ${done}`);
await session.waitForQuiet({ quietMs: 5000, timeoutMs: 60_000 });
} finally {
stopMirror();
await session.close();
}
saveSession(ctx, '', session, {
promptDeviation: 'unattended persona appendix + local runbook path + preinstalled binary',
runbook: 'BOOTSTRAP_FOR_AGENTS.md (local)',
});
}
// ── scenario: drive (manual control channel) ─────────────────────────────────
async function scenarioDrive(ctx: ScenarioCtx, args: CliArgs): Promise<void> {
if (args.driveArgv.length === 0) {
log('drive mode needs a command: dx-explore.ts drive -- gbrain init');
process.exit(2);
}
// `gbrain` as argv[0] resolves to the compiled binary.
const argv = [...args.driveArgv];
if (argv[0] === 'gbrain') argv[0] = ctx.gbrainBin;
const sessDir = path.join(ctx.outDir, 'session');
fs.mkdirSync(sessDir, { recursive: true });
const inputPath = path.join(sessDir, 'input.jsonl');
fs.writeFileSync(inputPath, '');
const env: Record<string, string | undefined> = {};
if (args.hermeticHome) {
const home = tmp(ctx, 'gb-dx-home-');
env.HOME = home;
env.GBRAIN_HOME = home;
}
log(`driving: ${argv.join(' ')}`);
log(`watch: cat ${path.join(sessDir, 'screen.txt')}`);
log(`input: echo '{"line":"some text"}' >> ${inputPath} (sends text + Enter)`);
log(` echo '{"key":"Down"}' >> ${inputPath}`);
log(`stop: echo '{"stop":true}' >> ${inputPath}`);
const session = launchTty(argv, {
cwd: process.cwd(),
env,
timeoutMs: 3_600_000,
});
const stopMirror = mirrorSession(ctx.outDir, session);
let offset = 0;
let stopping = false;
try {
while (!session.exited() && !stopping) {
await Bun.sleep(200);
let content = '';
try {
content = fs.readFileSync(inputPath, 'utf8');
} catch {
continue;
}
if (content.length <= offset) continue;
const fresh = content.slice(offset);
offset = content.length;
for (const line of fresh.split('\n')) {
if (!line.trim()) continue;
const cmd = parseDriveCommand(line);
if (!cmd) {
log(`skipping malformed drive command: ${line.slice(0, 120)}`);
continue;
}
if (cmd.kind === 'send') {
event(ctx, 'input', cmd.data);
session.send(cmd.data);
} else if (cmd.kind === 'key') {
event(ctx, 'input', `<${cmd.key}>`);
session.sendKey(cmd.key);
} else if (cmd.kind === 'note') {
event(ctx, 'note', cmd.text);
} else if (cmd.kind === 'stop') {
stopping = true;
break;
}
}
}
} finally {
stopMirror();
await session.close();
}
saveSession(ctx, '', session, { mode: 'drive', command: argv.join(' ') });
}
// ── main ─────────────────────────────────────────────────────────────────────
const SCENARIOS: Record<string, { needsGbrain: boolean; run: (ctx: ScenarioCtx, args: CliArgs) => Promise<void> }> = {
help: { needsGbrain: true, run: scenarioHelp },
init: { needsGbrain: true, run: scenarioInit },
'claude-install': { needsGbrain: true, run: scenarioClaudeInstall },
'codex-install': { needsGbrain: true, run: scenarioCodexInstall },
drive: { needsGbrain: true, run: scenarioDrive },
};
async function main(): Promise<void> {
const args = parseArgs(process.argv.slice(2));
const scenario = SCENARIOS[args.scenario];
if (!scenario) {
log(`usage: bun run scripts/dx-explore.ts <${Object.keys(SCENARIOS).join('|')}> [options] [-- cmd...]`);
process.exit(2);
}
const ctx = newCtx(args, scenario.needsGbrain);
log(`transcripts → ${ctx.outDir}`);
try {
await scenario.run(ctx, args);
} finally {
finishCtx(ctx);
}
}
await main();
+6
View File
@@ -34,6 +34,12 @@ export const E2E_TEST_MAP: Record<string, string[]> = {
"test/e2e/openclaw-context-engine-plugin.test.ts",
"test/e2e/openclaw-plugin-load-real.test.ts",
],
// claw-test harness (command + core: runners, scenarios, seeding, friction
// merge) feeds the scripted + shim-live E2E. The hermes door
// (install-real-hermes.serial.test.ts) is deliberately NOT mapped — it is
// opt-in-gated (GBRAIN_REAL_HERMES_E2E) and self-skips in run-all anyway.
"src/commands/claw-test.ts": ["test/e2e/claw-test.test.ts"],
"src/core/claw-test/**": ["test/e2e/claw-test.test.ts"],
// dream.ts is a thin alias over runCycle in cycle.ts.
"src/core/cycle.ts": ["test/e2e/cycle.test.ts", "test/e2e/dream.test.ts"],
// Multi-source sync writes share the per-source bookmark anchor.
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env bun
/**
* E6 auto-generate docs/TOOL_CATALOG.md from src/mcp/tool-catalog.ts.
*
* Run: bun run scripts/generate-tool-catalog.ts
*
* CI guard `scripts/check-tool-catalog-fresh.sh` (in `bun run verify`)
* regenerates and diffs against the committed version an out-of-date doc
* fails the build. Mirrors the METRIC_GLOSSARY generator pattern
* (scripts/generate-metric-glossary.ts).
*/
import { writeFileSync, mkdirSync } from 'fs';
import { dirname, join, resolve } from 'path';
import { fileURLToPath } from 'url';
import { renderToolCatalogMarkdown } from '../src/mcp/tool-catalog.ts';
const __dirname = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = resolve(__dirname, '..');
const OUT_PATH = join(REPO_ROOT, 'docs', 'TOOL_CATALOG.md');
const md = renderToolCatalogMarkdown() + '\n';
mkdirSync(dirname(OUT_PATH), { recursive: true });
writeFileSync(OUT_PATH, md, 'utf-8');
console.log(`Wrote ${OUT_PATH} (${md.length} bytes, ${md.split('\n').length} lines).`);
+13 -10
View File
@@ -68,16 +68,19 @@ mkdir -p "$E2E_TMP_HOME/.gbrain"
# --- Hermetic env scrub: operator/agent context must not bleed into E2E ---
# A dev shell or a Conductor workspace exports CONDUCTOR_*, MCP_*, OPENCLAW_*,
# and GBRAIN_* config overrides (e.g. a stray GBRAIN_BRAIN_ID, GBRAIN_SOURCE,
# GBRAIN_*_THRESHOLD, GBRAIN_SUPERVISOR_PID_FILE) that would silently change
# test behavior — making "hermetic" E2E non-hermetic and its failures
# unreproducible across machines. Drop them before bun starts. This is a
# DENYLIST of operator-context prefixes (not an allowlist rebuild), so PATH,
# HOME, TMPDIR, CI, DATABASE_URL, and bun internals survive untouched. We keep
# GBRAIN_HOME (just set above for HOME isolation); everything else GBRAIN_* is
# an operator override the suite must not inherit. Adapts GStack's
# buildHermeticEnv() allowlist to gbrain's shell E2E runner.
for _e2e_var in $(env | grep -oE '^(CONDUCTOR_|MCP_|OPENCLAW_|GBRAIN_)[A-Za-z0-9_]*' | sort -u); do
# HERMES_*, and GBRAIN_* config overrides (e.g. a stray GBRAIN_BRAIN_ID,
# GBRAIN_SOURCE, GBRAIN_*_THRESHOLD, GBRAIN_SUPERVISOR_PID_FILE, an operator's
# HERMES_BIN/HERMES_HOME) that would silently change test behavior — making
# "hermetic" E2E non-hermetic and its failures unreproducible across machines.
# Drop them before bun starts. This is a DENYLIST of operator-context prefixes
# (not an allowlist rebuild), so PATH, HOME, TMPDIR, CI, DATABASE_URL, and bun
# internals survive untouched. We keep GBRAIN_HOME (just set above for HOME
# isolation); everything else GBRAIN_* is an operator override the suite must
# not inherit — which also scrubs GBRAIN_REAL_HERMES_E2E, so the paid hermes
# door suite structurally cannot fire under this runner (its venue is
# heavy-tests.yml's direct bun test). Adapts GStack's buildHermeticEnv()
# allowlist to gbrain's shell E2E runner.
for _e2e_var in $(env | grep -oE '^(CONDUCTOR_|MCP_|OPENCLAW_|HERMES_|GBRAIN_)[A-Za-z0-9_]*' | sort -u); do
case "$_e2e_var" in
GBRAIN_HOME) ;; # required for HOME isolation (set above) — keep
*) unset "$_e2e_var" || true ;;
+1
View File
@@ -51,6 +51,7 @@ CHECKS=(
"check:cli-exec"
"check:system-of-record"
"check:eval-glossary"
"check:tool-catalog"
"check:skills-manifest"
"check:no-pii-agent-voice"
"check:synthetic-corpus-privacy"
+2 -1
View File
@@ -102,7 +102,8 @@ wins; fix the row.
|---------|-------|
| "Set up GBrain", first boot | `skills/setup/SKILL.md` |
| "Now what?", "fill my brain", "cold start", "bootstrap my data", "import my data", "what should I import first" | `skills/cold-start/SKILL.md` |
| "Install gbrain into this agent/harness", "agent workspace bootstrap", "gbrain bootstrap", "wire gbrain hooks", "set up the maintenance sweep" | Run `gbrain bootstrap` (paste-in harness install: hooks + sweep + config). See `docs/guides/bootstrap.md` |
| "agent workspace bootstrap", "install gbrain into this agent workspace", "gbrain bootstrap", "paste-in install", "set up the maintenance sweep" | Run `gbrain bootstrap` (paste-in workspace install: interview + identity files + hooks + sweep). See `docs/guides/bootstrap.md` |
| "wire this box's coding agents to the brain", "framework-spawned sessions need brain access", "wire gbrain hooks without a workspace", "hook Claude Code/Codex to the running serve" | Run `gbrain bootstrap harness --yes` (machine-level wiring to a running `serve --http`: scoped token + user-scope MCP + headless pre-approval + hooks; no agent.json). See the "Local harness mode" section of `docs/guides/bootstrap.md` |
| "Migrate from Obsidian/Notion/Logseq" | `skills/migrate/SKILL.md` |
| Brain health check, maintenance run | `skills/maintain/SKILL.md` |
| "Extract links", "build link graph", "populate timeline" | `skills/maintain/SKILL.md` (extraction sections) |
+1
View File
@@ -55,6 +55,7 @@ gbrain friction list # recent runs with counts
gbrain friction render --run-id <id> # markdown report (default)
gbrain friction render --run-id <id> --json
gbrain friction summary --run-id <id> # friction + delight side-by-side
gbrain friction diff --base <run-or-agent> --compare <run-or-agent> # cross-run/cross-agent comparison
```
`render` defaults to `--redact` for markdown (strips `$HOME`/`$CWD` to `<HOME>`/`<CWD>` placeholders) so reports paste safely into PRs and issues.
+1 -1
View File
@@ -159,7 +159,7 @@ mismatch, typo'd `--type`) before reporting anything.
```bash
gbrain link-sources # citation-graph should appear with the expected count
gbrain check-backlinks # confirm no orphaned references
gbrain check-backlinks check # confirm no orphaned references
```
## Run it (worked example, synthetic fixture)
+28 -13
View File
@@ -139,13 +139,17 @@ cd "$BRAIN"
gbrain recall --grep "salary"
```
Collect every returned slug into the scope list.
Resolve every returned slug to its repo-relative file path and write the
paths into `/tmp/brainify-scope.txt` (one per line). This file is the
scope list; the structural pass below APPENDS to it — nothing later in
the procedure may truncate it, or the retrieval-discovered pages
silently drop out of scope.
2. Structural discovery — people files that belong to the company, plus
keyword hits across the wider scan scope:
```bash
grep -rli 'company: *"acme-example"' people/ --include="*.md" | sort > /tmp/brainify-scope.txt
grep -rli 'company: *"acme-example"' people/ --include="*.md" | sort >> /tmp/brainify-scope.txt
grep -rli -E 'salary|equity|carry|retention|underperform|performance review|hard conversation' \
meetings/ daily/ companies/ projects/ analysis/ --include="*.md" 2>/dev/null >> /tmp/brainify-scope.txt
sort -u -o /tmp/brainify-scope.txt /tmp/brainify-scope.txt
@@ -254,16 +258,22 @@ For sanitization, sensitive fact rows must be ACTUALLY REMOVED: find them
(`gbrain recall --grep`), then delete the row from the page's Facts fence
(step 5), exactly like a sensitive take. On an in-place shared brain, the
page edit must then be re-synced (`gbrain sync` re-imports the edited page)
so the shared database no longer serves the row — an edited page over an
un-synced DB still leaks through retrieval. `forget` alone can never certify
a brain clean.
AND the facts index reconciled — sync's convergence contract covers page
import only; downstream fact extraction is explicitly decoupled
(`src/commands/sync.ts`, "CONVERGENCE CONTRACT"), so the DB keeps serving
the deleted row until the extract-facts reconcile runs. Trigger it
(`gbrain sweep`, or wait for the serve-resident sweep), then confirm with
`gbrain recall --grep` that the row is actually gone. An edited page over
an un-reconciled facts index still leaks through retrieval. `forget` alone
can never certify a brain clean.
After edits: on the **staging-copy** path the fact rows are removed by editing
the copied markdown directly (there is no live DB to re-sync yet — the team DB
is built fresh when Phase 5 Step 0 turns the export into a source). On the
**in-place shared-brain** path, `gbrain sync` re-imports the changed pages so
the DB matches the markdown. Either way, run `gbrain check-backlinks check` to
catch pages still pointing at removed content.
**in-place shared-brain** path, run `gbrain sync` so the page content matches
the markdown, then reconcile and verify the facts index as above. Either way,
run `gbrain check-backlinks check` to catch pages still pointing at removed
content.
### Phase 4: Verify
@@ -502,7 +512,10 @@ recovery line.
mirror-clone backup in `~/.gbrain/backups/` for a retention window
(~30 days is a sane default), then delete it — it contains the
pre-sanitization history and should not accumulate indefinitely:
`rm -rf ~/.gbrain/backups/brain-history-backup-<date>.git`
`rm -rf ~/.gbrain/backups/shared-brain-history-backup-<date>.git`
(the glob must match the `shared-brain-history-backup-*` name the backup
step created — a mismatched pattern deletes nothing and silently retains
the pre-sanitization history forever)
- If the repo carries push hooks or auto-hardening wiring, re-verify remotes
and hooks survived the rewrite before handing the repo to the team
@@ -592,9 +605,11 @@ This skill guarantees:
covered by the sanitization scan; everything else is excluded by default,
and the Phase 4 verification greps run against the exported tree before
the first push.
- Sensitive fact rows are deleted from the page's Facts fence and re-synced,
never merely expired — `gbrain forget` retains the row (struck through,
served via `--include-expired`) and can never certify clean.
- Sensitive fact rows are deleted from the page's Facts fence, re-synced,
and the facts index reconciled (extract-facts sweep) with the removal
verified via `gbrain recall --grep`, never merely expired — `gbrain
forget` retains the row (struck through, served via `--include-expired`)
and can never certify clean.
- The history-purge filter list and its restore manifest both derive from
the COMPLETE set of sanitized paths, never a subset.
- Every strip decision is a per-file model judgment grounded in a full read;
@@ -623,7 +638,7 @@ Three artifacts:
- Scope: [N files scanned across people/, meetings/, daily/, ...]
- Flagged: [M files with hits] (triage list attached)
- Edited: [K files sanitized; T takes removed; F fact rows removed + re-synced]
- Edited: [K files sanitized; T takes removed; F fact rows removed + re-synced + facts index reconciled]
- Verification: [grep residuals: 0 confirmed-sensitive; retrieval checks: clean]
- History: [not purged | fresh-export | purged after confirmed gate — backup at <path>]
- Next re-audit: [date / cron slot]
-1
View File
@@ -7,7 +7,6 @@
"schema-author": "schema-pack authoring is a brain-owner activity, not a client activity",
"smoke-test": "host-runtime health checks (container/daemon assumptions)",
"gbrain-upgrade": "host binary upgrade flow",
"cold-start": "host onboarding flow",
"schema-unify": "schema unification is a brain-owner migration activity",
"skill-optimizer": "requires host-side skillopt engine access and benchmark files"
}
+4 -1
View File
@@ -19,7 +19,10 @@ Set up GBrain from scratch. Target: working brain in under 5 minutes.
> **Installing into an agent harness?** (Claude Code, Codex, OpenClaw, etc.)
> `gbrain bootstrap` is the paste-in install path — it wires hooks, the
> maintenance sweep, and harness config in one command. See
> maintenance sweep, and harness config in one command. On a box that already
> hosts a brain + a running `gbrain serve --http` (agent-framework boxes),
> `gbrain bootstrap harness --yes` wires framework-spawned Claude Code/Codex
> sessions instead — no agent workspace needed. See
> `docs/guides/bootstrap.md`. This skill covers the brain-side setup
> (database, sync, first import); the two are complementary.
+6 -6
View File
@@ -1,9 +1,9 @@
{
"RESOLVER.md": "3d32ec5cd8c15d83b18469277a5efc269bb6b60e67f4542de5a28a76760c36cd",
"RESOLVER.md": "8e54195c109c764d2954186ee92a30a62cd91e961233be53f828db5a25ebe710",
"_AGENT_README.md": "62613f7f1e061576b6c1b18844f59bd35f2df96ca5c45c8c41fae0772b9ce4d3",
"_brain-filing-rules.json": "cf850df6a7425464c6d63b3ace71991cc93497fa0cc8cd21acd31883e17939c6",
"_brain-filing-rules.md": "2d2d75b7c76081c56f41b2c0a5a978c355ce957300f9b0a5575dc4079ef1f877",
"_friction-protocol.md": "1b6e7cfa58725a6a5dc2dc787242141bc33f5fde524540d85b14ec22266140f7",
"_friction-protocol.md": "51353207240142024ff1facc25f225712275ecdb4a034ffffdd83740c8d328e3",
"_output-rules.md": "0722ec2ecea7f9fa2f065cf12dfe1347956a9709d29898bf9fe95e875c64b800",
"academic-verify/SKILL.md": "1c19e27e75249d869da428ce8d060075feef8fbbfe146af58b305d11a260ebbc",
"academic-verify/routing-eval.jsonl": "90d894a9829d9936e6ac7a6507e4de67ad26e46a1fe13b7a34e7dec1c0d887dd",
@@ -33,10 +33,10 @@
"capture/SKILL.md": "98568ac96331f57397ea072749641d9748b1ce31e8b09d512b8db25c8fcda65f",
"citation-fixer/SKILL.md": "abdadbf0740a529b9c4f86f05bba416417624503fdcbc6054402d5546afd08b4",
"citation-fixer/routing-eval.jsonl": "52b23b71e66fdc18aee67d0576099b0c83997d648cf4ecf8fe7753b91b6c9c53",
"citation-graph-ingest/SKILL.md": "6510856cc14a653dcade510702890343bc0527de14bc2f1ed0d2524f248c798c",
"citation-graph-ingest/SKILL.md": "849b0cdc64b7ff14d0e6771bde15f0edc3c2fc29af08be015753a5f88a03205f",
"citation-graph-ingest/routing-eval.jsonl": "a1ba605d35e736b741b9e8aac1e7d50b61a7cbcada893d67099b55bf5a0d2635",
"cold-start/SKILL.md": "20be3d1b637621fd9fbd268072f6647533a23f596e30cb593523b051708aaddd",
"company-brainify/SKILL.md": "2c058b39f5364b8ceb5c53b4525cce8645734f16cc3c229a490b005d58a78311",
"company-brainify/SKILL.md": "ae48372512645f532820e43faaf18a8fa768a691b2144973dfc89465f84d84c6",
"company-brainify/routing-eval.jsonl": "6f27f835eda9ae77a2b694534c78a043a871349820e8c638c3d8bbba6d3aa17b",
"concept-synthesis/SKILL.md": "ed02d2e385143b16a1e69ee5934288fb4d0b755f68c4312faff663e6b2d7c4ed",
"concept-synthesis/routing-eval.jsonl": "96dbd7d9c1b606e9e06262d0c06282399741e2bccb8eeb7b9ca88c20f44cda0e",
@@ -137,7 +137,7 @@
"minion-orchestrator/routing-eval.jsonl": "501ed2e19cb16847ff8425219d246b7a774de1accd42cb28fd44edbb64204992",
"perplexity-research/SKILL.md": "c25f5c471cbe3c6e0f975d8397e8382b00a85f8aa75302231d53c52855369e97",
"perplexity-research/routing-eval.jsonl": "f1a40d87e710d5d2acd602a372d83f46c95da022b6e635228fffeaacb3bb2b27",
"plugin-exclusions.json": "585486aaaf9a87ec4b13bea5d03f5e9af34a9ac64283c878c234ba094126f793",
"plugin-exclusions.json": "e8070da59bb4444304eb354c7421f0998383454d96d5e66e38484aae792c4775",
"publish/SKILL.md": "e06b609db780a3cc93a1755a87b30ff08ffdc0fdbc834c1422b2ad2489b57497",
"query/SKILL.md": "b12aae4e86b893038b1d9e97a977bd6a7939db9f5c57dde12c11d8d7451e0762",
"query/routing-eval.jsonl": "74f5a91e52fabc54e0e9403fa17db87ee26bb7ebb8ae8005148c51142abc62fe",
@@ -150,7 +150,7 @@
"resolve-before-asking/routing-eval.jsonl": "bac1bcf30337f5255ef4ce1a2a8a2b38d58ebcd576503c483190c79ec6e69489",
"schema-author/SKILL.md": "09d69ee45970191bb2592a764685f67196cabf350db40b4c0c5ffc19ea9e2df3",
"schema-unify/SKILL.md": "14ddc0f8bc7d8b11eb03dc4eb35621d140fba71bd35779835d85e297acfb0177",
"setup/SKILL.md": "68dde0de48bb4c93b13d9e19c3a20ff82155985ad09f509669aff8ece0a6f4fb",
"setup/SKILL.md": "322faf7099afd71f9add0f6dbe4d2bf9686df54c521a29e018f4ab25608e3dc0",
"signal-detector/SKILL.md": "64e4547f5a8624c53d875001b423d240ec73ee9fd026a96c7b799d287c5fb6e4",
"skill-autobench/SKILL.md": "144572ec76f3784a97645dfde587ab13d77e804f50b00dc7fbe678204de6ff21",
"skill-autobench/routing-eval.jsonl": "8d961ed6403b7e2f690948e4c18529d40f26f6f21064befc56d465966b1a9ec0",
+91 -14
View File
@@ -14,6 +14,7 @@ import { spawn } from 'child_process';
import {
readUpdateCache,
isCacheFresh,
pendingUpgradeVersion,
readSnooze,
isSnoozeActive,
resolveSelfUpgradeMode,
@@ -32,7 +33,7 @@ import { serializeMarkdown } from './core/markdown.ts';
import { parseGlobalFlags, setCliOptions, getCliOptions } from './core/cli-options.ts';
import { conceptNudge } from './core/search/query-intent.ts';
import type { CliOptions } from './core/cli-options.ts';
import { callRemoteTool, RemoteMcpError, unpackToolResult } from './core/mcp-client.ts';
import { callRemoteTool, RemoteMcpError, unpackToolResult, extractResponseMeta } from './core/mcp-client.ts';
import { maybePromptForUpgrade } from './core/thin-client-upgrade-prompt.ts';
import { CLI_FLAG_REGISTRY } from './core/cli-flag-registry.generated.ts';
import { VERSION } from './version.ts';
@@ -259,12 +260,26 @@ function maybeEmitUpdateMarker(command: string): void {
const now = Date.now();
const entry = readUpdateCache();
if (entry && isCacheFresh(entry, now)) {
if (entry.marker.kind === 'upgrade_available' && entry.marker.latest) {
// Shared stale/foreign-cache guard (pendingUpgradeVersion): only nag when
// the cached latest is strictly newer than the RUNNING binary, and print
// the running version — the cache records whatever binary WROTE it.
const latest = pendingUpgradeVersion(VERSION, now);
if (latest) {
// notify mode honors a per-version snooze; auto mode ignores it.
if (mode === 'notify' && isSnoozeActive(readSnooze(), entry.marker.latest, now)) return;
process.stderr.write(`UPGRADE_AVAILABLE ${entry.marker.current} ${entry.marker.latest}\n`);
if (mode === 'notify' && isSnoozeActive(readSnooze(), latest, now)) return;
// The raw `UPGRADE_AVAILABLE <cur> <latest>` line is a MACHINE marker
// (parsed by the self-upgrade skill / MCP via parseMarker). A human at
// an interactive terminal should never see the token as the literal
// first line of output — so emit it only when stderr is NOT a TTY
// (agent harnesses capture stderr non-interactively and still get it).
// GBRAIN_FORCE_UPGRADE_MARKER=1 forces it for the rarer agent harness
// that allocates a PTY yet still parses the token. The human sentence
// prints on both.
if (!process.stderr.isTTY || process.env.GBRAIN_FORCE_UPGRADE_MARKER === '1') {
process.stderr.write(`UPGRADE_AVAILABLE ${VERSION} ${latest}\n`);
}
process.stderr.write(
`gbrain ${entry.marker.current} -> ${entry.marker.latest} available. Run: gbrain self-upgrade\n`,
`gbrain ${VERSION} -> ${latest} available. Run: gbrain self-upgrade\n`,
);
}
return;
@@ -273,19 +288,32 @@ function maybeEmitUpdateMarker(command: string): void {
// Stale/missing cache → kick a detached, single-flighted refresh. The child
// (`check-update --refresh-cache`) single-flights via the refresh lock and
// writes the cache for the NEXT invocation. We never wait on it.
// Spawn OURSELVES (hook.ts spawnDetachedPush pattern), not `gbrain` from
// PATH — a different (older) binary on PATH would write ITS version into
// the cache and make the marker lie about what is installed here.
try {
const child = spawn('gbrain', ['check-update', '--refresh-cache'], {
const exec = process.execPath ?? '';
const refreshArgs = ['check-update', '--refresh-cache'];
// Detect compiled-vs-dev by the RUNTIME's basename, not our own — a
// published binary keeps its official name (`gbrain-darwin-arm64`, a
// `gb` shim), so matching `/gbrain$/` on execPath would misfire and
// prepend the `/$bunfs/root/...` virtual entrypoint (process.argv[1] in
// a compiled Bun binary), producing an unknown-command child that never
// refreshes. Dev mode runs under `bun`/`node`; anything else IS the
// compiled binary and re-execs itself directly.
const isDevRuntime = /[/\\](bun|node)(\.exe)?$/.test(exec);
const argv = isDevRuntime ? [process.argv[1], ...refreshArgs] : refreshArgs;
const child = spawn(exec, argv, {
detached: true,
stdio: 'ignore',
env: { ...process.env, GBRAIN_SKIP_STARTUP_HOOKS: '1' },
});
// ChildProcess is an EventEmitter — an unhandled 'error' (e.g. ENOENT when
// gbrain isn't on PATH) would throw uncaught. Swallow it; the refresh is
// best-effort.
// ChildProcess is an EventEmitter — an unhandled 'error' would throw
// uncaught. Swallow it; the refresh is best-effort.
child.on('error', () => {});
child.unref();
} catch {
/* gbrain not on PATH / spawn failed — fail-open, no refresh this run */
/* spawn failed — fail-open, no refresh this run */
}
} catch {
/* the update marker must never break a command */
@@ -692,6 +720,10 @@ async function runThinClientRouted(
timeoutMs,
signal: sigintController.signal,
});
// T15/FOV-1: lift the server's retrieval meta off the envelope before
// unpacking (old servers lack _meta — capture is simply skipped).
const envelopeMeta = extractResponseMeta(raw);
if (envelopeMeta?.retrieval) captureRetrievalMeta('retrieval', envelopeMeta.retrieval);
const result = unpackToolResult(raw);
const output = formatResult(op.name, result, params);
if (output) process.stdout.write(output);
@@ -1279,9 +1311,50 @@ export async function makeContext(engine: BrainEngine, params: Record<string, un
// brain (that would be an untrusted-caller cross-brain hole over MCP).
brainId: activeBrainId,
...(localFederated ? { localFederatedSourceIds: localFederated } : {}),
// T15/FOV-1: capture the retrieval meta for formatResult's empty-result
// render (the local-engine twin of the MCP _meta.retrieval channel).
emitResponseMeta: captureRetrievalMeta,
};
}
/**
* T15/FOV-1: the retrieval meta for the CURRENT CLI invocation, captured
* from either result path the local engine path via ctx.emitResponseMeta,
* the thin-client routed path via the envelope's `_meta.retrieval`. Read by
* formatResult's empty-result branch so `gbrain search`/`query` stop
* printing a bare "No results." when the pipeline actually degraded.
* Module state is safe here: one op per CLI process.
*/
let lastRetrievalMeta: Record<string, unknown> | null = null;
export function captureRetrievalMeta(key: string, value: unknown): void {
if (key === 'retrieval' && value !== null && typeof value === 'object') {
lastRetrievalMeta = value as Record<string, unknown>;
}
}
// Exported for tests.
export function resetRetrievalMetaForTests(): void {
lastRetrievalMeta = null;
}
/** One-line parenthetical for the empty-result render. '' when no meta. */
function describeEmptyRetrieval(): string {
const m = lastRetrievalMeta;
if (!m) return '';
const parts: string[] = [];
if (typeof m.retrieved_count === 'number' && m.retrieved_count > 0) {
parts.push(`retrieved ${m.retrieved_count} before trimming`);
}
const stages = Array.isArray(m.degraded)
? [...new Set((m.degraded as Array<{ stage?: string }>).map(d => d?.stage).filter(Boolean))]
: [];
parts.push(stages.length > 0
? `degraded: ${stages.join(', ')}`
: 'clean miss — no retrieval degradation');
return ` (${parts.join('; ')})`;
}
// Exported for tests (same import-safety contract as cliAliases/printOpHelp).
/**
* #2416: hint-only steering a concept-shaped `search` gets a one-line
@@ -1341,7 +1414,10 @@ export function formatResult(
case 'query': {
const results = result as any[];
if (params.json === true) return JSON.stringify(results, null, 2) + '\n';
if (results.length === 0) return 'No results.\n';
// T15/FOV-1: an empty result names its cause when the pipeline told us
// (degradation stages from _meta.retrieval / the local meta capture) —
// a bare "No results." was indistinguishable from a degraded pipeline.
if (results.length === 0) return `No results.${describeEmptyRetrieval()}\n`;
// v0.40.4 — --explain switches to per-stage attribution formatter.
// Reads CliOptions.explain via the module-level singleton.
const cliOpts = getCliOptions();
@@ -3028,7 +3104,7 @@ SETUP
migrate embeddings --to <p:model> Re-embed onto another embedding provider
upgrade Self-update
check-update [--json] Check for new versions
doctor [--json] [--fast] Health check (resolver, skills, pgvector, RLS, embeddings)
doctor [--json] [--fast] [--probe-pglite] Health check (resolver, skills, pgvector, RLS, embeddings; --probe-pglite runs the scratch-store probe)
integrations [subcommand] Manage integration recipes (senses + reflexes)
PAGES
@@ -3168,8 +3244,9 @@ ADMIN
storage status [--repo <path>] Storage tier status and health
[--json] (git-tracked vs supabase-only)
serve MCP server (stdio)
--surface verbs|full Tool surface: the 5 memory verbs only, or
every op (default full; verbs = quickstart)
--surface verbs|starter|full Tool surface: the 7 memory verbs, the ~20-op
starter set, or every op (default full).
On --http this is the per-client CEILING.
serve --http [--port N] HTTP MCP server with OAuth 2.1
--token-ttl N Access token TTL in seconds (default: 3600)
--enable-dcr Enable Dynamic Client Registration (DCR clients default to authorization_code)
+82 -3
View File
@@ -18,6 +18,8 @@ import type { BrainEngine } from '../core/engine.ts';
import { MinionQueue } from '../core/minions/queue.ts';
import { waitForCompletion, TimeoutError } from '../core/minions/wait-for-completion.ts';
import type { MinionJobInput, SubagentHandlerData, AggregatorHandlerData } from '../core/minions/types.ts';
import { resolveSourceId, ALL_SOURCES } from '../core/source-resolver.ts';
import { fetchSource } from '../core/sources-load.ts';
import { runAgentLogs } from './agent-logs.ts';
// ── arg parsing helpers ────────────────────────────────────
@@ -72,6 +74,10 @@ SUBMITTING
--max-turns <n> Max assistant turns (default 20)
--tools a,b,c Subset of registered tool names (comma list)
--timeout-ms <n> Per-job wall-clock timeout
--source <id> Brain source the subagent's writes are scoped to.
Default: the standard resolution chain (GBRAIN_SOURCE,
.gbrain-source, sources.default, ...) see
\`gbrain sources current\`
--fanout-manifest <path> JSON array of {prompt, input_vars?} one child each
--follow Tail status until terminal (default on TTY)
--detach Submit + print job id, exit immediately
@@ -116,6 +122,7 @@ interface RunFlags {
maxTurns?: number;
tools?: string[];
timeoutMs?: number;
source?: string;
fanoutManifest?: string;
follow: boolean;
detach: boolean;
@@ -181,6 +188,7 @@ function parseRunFlags(args: string[]): { flags: RunFlags; rest: string[] } {
case '--max-turns': flags.maxTurns = parseIntFlagValue(requireFlagValue(args, ++i, a), a); break;
case '--tools': flags.tools = requireFlagValue(args, ++i, a).split(',').map(s => s.trim()).filter(Boolean); break;
case '--timeout-ms': flags.timeoutMs = parseIntFlagValue(requireFlagValue(args, ++i, a), a); break;
case '--source': flags.source = requireFlagValue(args, ++i, a); break;
case '--fanout-manifest': flags.fanoutManifest = requireFlagValue(args, ++i, a); break;
case '--follow': flags.follow = true; break;
case '--no-follow': flags.follow = false; break;
@@ -203,17 +211,86 @@ function parseRunFlags(args: string[]): { flags: RunFlags; rest: string[] } {
return { flags, rest };
}
/**
* Predicate: is this error one of the source resolver's user-facing throws
* we want to surface as a clean stderr line + exit 1? Mirrors
* dream.ts:isResolverUserError anything else (connection failures,
* genuine bugs) propagates with a stack trace.
*/
function isResolverUserError(e: unknown): boolean {
if (!(e instanceof Error)) return false;
const m = e.message;
return (m.startsWith('Source "') && m.includes(' not found.'))
|| m.startsWith('Invalid --source value')
|| m.startsWith('Invalid GBRAIN_SOURCE value');
}
/**
* #2922: resolve the brain source for a subagent submission via the
* canonical chain (explicit --source GBRAIN_SOURCE .gbrain-source
* local_path match sources.default sole non-default 'default').
* Pre-fix, `gbrain agent run` never resolved a source, so every page an
* agent job wrote landed in the seed 'default' source even on brains with
* `gbrain sources default <id>` configured.
*
* The `__all__` sentinel is rejected here: subagent writes must target
* exactly one source (and `validateSourceId` at tool-registry build time
* would reject it anyway better to fail at submit than at claim).
*/
async function resolveAgentSource(engine: BrainEngine, explicit: string | undefined): Promise<string> {
// An empty `--source ""` must fail loudly, not silently degrade to the
// env/dotfile/default tiers (resolveSourceId's `if (explicit)` treats a
// falsy value as omitted — explicit-but-empty would slip through).
if (explicit !== undefined && explicit.trim() === '') {
console.error('gbrain agent run: --source requires a non-empty value. Run `gbrain agent run --help`.');
process.exit(2);
}
let resolved: string;
try {
resolved = await resolveSourceId(engine, explicit ?? null);
} catch (e) {
if (isResolverUserError(e)) {
console.error(`gbrain agent run: ${(e as Error).message}`);
process.exit(1);
}
throw e;
}
if (resolved === ALL_SOURCES) {
console.error(
`gbrain agent run: --source ${ALL_SOURCES} is not supported — ` +
`subagent writes must target exactly one source. Pass a concrete --source <id>.`,
);
process.exit(2);
}
// Archived-source guard, mirroring dream.ts: writing subagent pages into
// an archived (normally invisible) source would mask them until restore.
const src = await fetchSource(engine, resolved);
if (src?.archived === true) {
console.error(
`gbrain agent run: source ${resolved} is archived; restore with ` +
`\`gbrain sources restore ${resolved}\` before submitting agent jobs`,
);
process.exit(1);
}
return resolved;
}
export async function runAgentRun(engine: BrainEngine, args: string[]): Promise<void> {
const { flags, rest } = parseRunFlags(args);
const queue = new MinionQueue(engine);
// #2922: resolve once at submit time; both the single-job and fan-out
// paths stamp it on SubagentHandlerData.source_id so buildOpContext
// scopes every tool call to it instead of the legacy 'default'.
const sourceId = await resolveAgentSource(engine, flags.source);
// Fan-out path: --fanout-manifest supplies explicit child inputs. The
// aggregator submits first (so its id is available as parent for each
// child); children submit with on_child_fail='continue' so mixed
// outcomes don't cascade; aggregator waits in waiting-children until
// Lane 1B's terminal-set check unblocks it.
if (flags.fanoutManifest) {
await runFanout(engine, queue, flags, rest.join(' '));
await runFanout(engine, queue, flags, rest.join(' '), sourceId);
return;
}
@@ -223,7 +300,7 @@ export async function runAgentRun(engine: BrainEngine, args: string[]): Promise<
process.exit(2);
}
const data: SubagentHandlerData = { prompt };
const data: SubagentHandlerData = { prompt, source_id: sourceId };
if (flags.subagentDef) data.subagent_def = flags.subagentDef;
if (flags.model) data.model = flags.model;
if (flags.maxTurns) data.max_turns = flags.maxTurns;
@@ -248,7 +325,7 @@ export async function runAgentRun(engine: BrainEngine, args: string[]): Promise<
// ── fan-out ───────────────────────────────────────────────
async function runFanout(engine: BrainEngine, queue: MinionQueue, flags: RunFlags, promptTemplate: string): Promise<void> {
async function runFanout(engine: BrainEngine, queue: MinionQueue, flags: RunFlags, promptTemplate: string, sourceId: string): Promise<void> {
const manifestPath = flags.fanoutManifest!;
let manifest: Array<{ prompt?: string; input_vars?: Record<string, unknown> }>;
try {
@@ -272,6 +349,7 @@ async function runFanout(engine: BrainEngine, queue: MinionQueue, flags: RunFlag
const entry = manifest[0]!;
const data: SubagentHandlerData = {
prompt: entry.prompt ?? promptTemplate,
source_id: sourceId,
...(entry.input_vars ? { input_vars: entry.input_vars } : {}),
...(flags.subagentDef ? { subagent_def: flags.subagentDef } : {}),
...(flags.model ? { model: flags.model } : {}),
@@ -303,6 +381,7 @@ async function runFanout(engine: BrainEngine, queue: MinionQueue, flags: RunFlag
for (const entry of manifest) {
const data: SubagentHandlerData = {
prompt: entry.prompt ?? promptTemplate,
source_id: sourceId,
...(entry.input_vars ? { input_vars: entry.input_vars } : {}),
...(flags.subagentDef ? { subagent_def: flags.subagentDef } : {}),
...(flags.model ? { model: flags.model } : {}),
+323 -36
View File
@@ -23,6 +23,9 @@ import { createHash, randomBytes } from 'crypto';
import { loadConfig, toEngineConfig } from '../core/config.ts';
import { createEngine } from '../core/engine-factory.ts';
import type { BrainEngine } from '../core/engine.ts';
import { assertAllowedScopes } from '../core/scope.ts';
import { TOKEN_ID_RE } from '../core/token-mint.ts';
import { normalizeTokenScopes } from '../core/legacy-token-scope.ts';
import { sqlQueryForEngine, executeRawJsonb, type SqlQuery } from '../core/sql-query.ts';
function hashToken(token: string): string {
@@ -66,8 +69,20 @@ async function withConfiguredSql<T>(
}
}
async function create(name: string, opts: { takesHolders?: string[] } = {}) {
if (!name) { console.error('Usage: auth create <name> [--takes-holders world,garry]'); process.exit(1); }
async function create(name: string, opts: { takesHolders?: string[]; scopes?: string[] } = {}) {
if (!name) { console.error('Usage: auth create <name> [--takes-holders world,garry] [--scopes read,write]'); process.exit(1); }
// #4043 least-privilege: validate scopes at mint time — the verify path
// treats a filtered-empty scopes array as DENY, so a typo must fail loudly
// here, never silently brick (or widen) the token.
if (opts.scopes !== undefined) {
try {
if (opts.scopes.length === 0) throw new Error('at least one scope is required');
assertAllowedScopes(opts.scopes);
} catch (e: any) {
console.error(`Invalid --scopes: ${e.message}`);
process.exit(1);
}
}
const token = generateToken();
const hash = hashToken(token);
@@ -84,17 +99,35 @@ async function create(name: string, opts: { takesHolders?: string[] } = {}) {
// through the wire-protocol type oid without the v0.12.0 double-encode
// bug class (verified by test/e2e/auth-permissions.test.ts:67 on
// Postgres and test/sql-query.test.ts on PGLite).
await executeRawJsonb(
engine,
`INSERT INTO access_tokens (name, token_hash, permissions)
VALUES ($1, $2, $3::jsonb)`,
[name, hash],
[permissions],
);
console.log(`Token created for "${name}" (takes_holders=${JSON.stringify(takesHolders)}):\n`);
//
// Scopes (when given) land in the original-schema scopes TEXT[] column
// via an array literal through a TEXT param — values are allowlisted,
// so the literal needs no quoting and runs identically on both engines.
// Omitted → NULL → the historical grandfathered full-access grant.
if (opts.scopes !== undefined) {
await executeRawJsonb(
engine,
`INSERT INTO access_tokens (name, token_hash, permissions, scopes)
VALUES ($1, $2, $4::jsonb, $3::text[])`,
[name, hash, `{${opts.scopes.join(',')}}`],
[permissions],
);
} else {
await executeRawJsonb(
engine,
`INSERT INTO access_tokens (name, token_hash, permissions)
VALUES ($1, $2, $3::jsonb)`,
[name, hash],
[permissions],
);
}
const scopeLine = opts.scopes !== undefined
? `scopes=${JSON.stringify(opts.scopes)}`
: 'scopes=full access (grandfathered — pass --scopes read,write to narrow)';
console.log(`Token created for "${name}" (takes_holders=${JSON.stringify(takesHolders)}, ${scopeLine}):\n`);
console.log(` ${token}\n`);
console.log('Save this token — it will not be shown again.');
console.log(`Revoke with: gbrain auth revoke "${name}"`);
console.log(`Revoke with: gbrain auth revoke "${name}" (or gbrain auth revoke --id <id> from auth list)`);
console.log(`Update visibility: gbrain auth permissions "${name}" set-takes-holders world,garry`);
});
} catch (e: any) {
@@ -121,10 +154,19 @@ async function permissions(name: string, action: string, value: string | undefin
}
const perms = { takes_holders: list };
// JSONB UPDATE via executeRawJsonb — same pattern as create() above.
// MERGE, never whole-object replace: `SET permissions = $2::jsonb`
// would silently DELETE every other grant key (source_id federation,
// and any future key) on a routine takes-holders edit — the grant-wipe
// class the #4043 review caught.
// The jsonb_typeof guard repairs rows carrying historical double-encode
// damage (a jsonb string/array scalar): `scalar || object` would produce
// a jsonb ARRAY and silently strand every grant, so a damaged left
// operand is reset to '{}' on edit — the old whole-replace semantics for
// damaged rows, merge semantics for healthy object rows.
const result = await executeRawJsonb(
engine,
`UPDATE access_tokens
SET permissions = $2::jsonb
SET permissions = (CASE WHEN jsonb_typeof(permissions) = 'object' THEN permissions ELSE '{}'::jsonb END) || $2::jsonb
WHERE name = $1
RETURNING id`,
[name],
@@ -142,10 +184,20 @@ async function permissions(name: string, action: string, value: string | undefin
}
}
/** Render a token row's scope grant honestly (#4043: NULL = grandfathered).
* Routes through the SAME normalizer the verify path uses the ops surface
* must never claim admin on a row the serve actually scopes or denies. */
export function renderTokenScopes(scopes: unknown): string {
const normalized = normalizeTokenScopes(scopes);
if (normalized === undefined) return 'admin (grandfathered)';
if (normalized.length === 0) return '(deny-all)';
return normalized.join(',');
}
async function list() {
await withConfiguredSql(async (sql) => {
const rows = await sql`
SELECT name, created_at, last_used_at, revoked_at
SELECT id, name, scopes, created_at, last_used_at, revoked_at
FROM access_tokens
ORDER BY created_at DESC
`;
@@ -153,20 +205,22 @@ async function list() {
console.log('No tokens found. Create one: gbrain auth create "my-client"');
return;
}
console.log('Name Created Last Used Status');
console.log('─'.repeat(80));
console.log('ID Name Scopes Created Last Used Status');
console.log('─'.repeat(126));
for (const r of rows) {
const id = String(r.id).padEnd(36);
const name = (r.name as string).padEnd(20);
const scopes = renderTokenScopes(r.scopes).padEnd(21);
const created = new Date(r.created_at as string).toISOString().slice(0, 19);
const lastUsed = r.last_used_at ? new Date(r.last_used_at as string).toISOString().slice(0, 19) : 'never'.padEnd(19);
const status = r.revoked_at ? 'REVOKED' : 'active';
console.log(`${name} ${created} ${lastUsed} ${status}`);
console.log(`${id} ${name} ${scopes} ${created} ${lastUsed} ${status}`);
}
});
}
async function revoke(name: string) {
if (!name) { console.error('Usage: auth revoke <name>'); process.exit(1); }
if (!name) { console.error('Usage: auth revoke <name> | auth revoke --id <uuid>'); process.exit(1); }
await withConfiguredSql(async (sql) => {
const rows = await sql`
UPDATE access_tokens SET revoked_at = now()
@@ -177,10 +231,36 @@ async function revoke(name: string) {
console.error(`No active token found with name "${name}".`);
process.exit(1);
}
if (rows.length > 1) {
console.log(`Note: ${rows.length} active tokens carried the name "${name}" — all revoked. Use revoke --id for precision.`);
}
console.log(`Token "${name}" revoked.`);
});
}
/** #4043: names are not unique revoke-by-id is the precise path. The
* revocation semantics are canonical in src/core/token-mint.ts
* (revokeLegacyTokenById); this CLI wrapper keeps its own UPDATE only to
* RETURN the name for the confirmation line keep the two in lockstep. */
async function revokeById(id: string) {
if (!id || !TOKEN_ID_RE.test(id)) {
console.error('Usage: auth revoke --id <uuid> (ids are shown by `gbrain auth list`)');
process.exit(1);
}
await withConfiguredSql(async (sql) => {
const rows = await sql`
UPDATE access_tokens SET revoked_at = now()
WHERE id = ${id}::uuid AND revoked_at IS NULL
RETURNING name
`;
if (rows.length === 0) {
console.error(`No active token found with id "${id}".`);
process.exit(1);
}
console.log(`Token "${rows[0].name}" (${id}) revoked.`);
});
}
async function test(url: string, token: string) {
if (!url || !token) {
console.error('Usage: auth test <url> --token <token>');
@@ -552,8 +632,19 @@ async function registerClient(name: string, args: string[]) {
* so widening happens here (trusted local CLI) or via the requireAdmin
* /admin/api/rescope-client endpoint.
*/
/**
* WP4: parse the `--surface` rescope value. 'clear' null (clears both
* surface AND surface_set_by); one of the three known surfaces itself;
* anything else undefined (caller errors out). Exported for unit tests.
*/
export function parseRescopeSurfaceValue(value: string): 'verbs' | 'starter' | 'full' | null | undefined {
if (value === 'clear') return null;
if (value === 'verbs' || value === 'starter' || value === 'full') return value;
return undefined;
}
async function rescopeClient(clientId: string, args: string[]) {
const usage = 'Usage: auth rescope-client <client_id> [--source SOURCE] [--federated-read SRC1,SRC2,...] [--bound-slug-prefixes P1,P2|none]';
const usage = 'Usage: auth rescope-client <client_id> [--source SOURCE] [--federated-read SRC1,SRC2,...] [--bound-slug-prefixes P1,P2|none] [--surface verbs|starter|full|clear]';
if (!clientId) {
console.error(usage);
process.exit(1);
@@ -564,6 +655,9 @@ async function rescopeClient(clientId: string, args: string[]) {
// array = replace. Lets roster churn (channel joins/leaves) update the
// write fence in place instead of register+rotate.
let boundSlugPrefixes: string[] | null | undefined;
// WP4: tri-state — undefined = untouched, null = clear ('clear'), value =
// set + surface_set_by='operator' (the lock request_tools cannot override).
let surface: 'verbs' | 'starter' | 'full' | null | undefined;
for (let i = 0; i < args.length; i += 2) {
const flag = args[i];
const value = args[i + 1];
@@ -579,28 +673,50 @@ async function rescopeClient(clientId: string, args: string[]) {
boundSlugPrefixes = value === 'none'
? null
: value.split(',').map(s => s.trim()).filter(Boolean);
} else if (flag === '--surface') {
surface = parseRescopeSurfaceValue(value);
if (surface === undefined) {
console.error(`Error: --surface must be verbs | starter | full | clear (got "${value}")`);
console.error(usage);
process.exit(1);
}
} else {
console.error(`Error: Unknown flag: ${flag}`);
console.error(usage);
process.exit(1);
}
}
if (sourceId === undefined && federatedRead === undefined && boundSlugPrefixes === undefined) {
console.error('Error: pass --source, --federated-read, and/or --bound-slug-prefixes');
if (sourceId === undefined && federatedRead === undefined && boundSlugPrefixes === undefined && surface === undefined) {
console.error('Error: pass --source, --federated-read, --bound-slug-prefixes, and/or --surface');
console.error(usage);
process.exit(1);
}
try {
await withConfiguredSql(async (sql) => {
await withConfiguredSql(async (sql, engine) => {
const { GBrainOAuthProvider } = await import('../core/oauth-provider.ts');
const provider = new GBrainOAuthProvider({ sql });
const result = await provider.rescopeClient(clientId, { sourceId, federatedRead, boundSlugPrefixes });
const result = await provider.rescopeClient(clientId, { sourceId, federatedRead, boundSlugPrefixes, surface });
// WP4 (amendment 32 / ENG-8): every surface mutation writes an audit
// row (this CLI, the admin endpoint, the request_tools persist).
if (surface !== undefined) {
const { writeSurfaceChangeAudit } = await import('../core/surface-audit.ts');
await writeSurfaceChangeAudit(engine, {
actor: 'operator',
client_id: clientId,
old: result.surfaceOld ?? null,
new: result.surface ?? null,
via: 'rescope_cli',
});
}
console.log(`OAuth client rescoped: "${result.clientName}" (${result.clientId})\n`);
console.log(` Write source: ${result.sourceId}`);
console.log(` Federated reads: ${result.federatedRead.join(', ') || '<none>'}`);
if (result.boundSlugPrefixes !== undefined) {
console.log(` Bound slug prefixes: ${result.boundSlugPrefixes?.join(', ') ?? '<none — full-source write authority>'}`);
}
if (result.surface !== undefined) {
console.log(` Tool surface: ${result.surface ?? '<cleared — server/config surface applies>'}${result.surface != null ? ' (operator-pinned; request_tools cannot override)' : ''}`);
}
console.log('\nTakes effect on the client\'s next request (existing tokens included).');
});
} catch (e: any) {
@@ -609,28 +725,176 @@ async function rescopeClient(clientId: string, args: string[]) {
}
}
/**
* E4 (WP4 expansion): `gbrain auth clients [--usage] [--days N] [--json]`.
*
* Lists OAuth clients with their scopes + per-client MCP tool surface
* (`surface` / `surface_set_by`, WP4), and with `--usage` joins the
* per-client op-call usage from `mcp_request_log` via the shared reader
* (src/core/mcp-usage.ts same hygiene rules as the E3 advisor collector
* and scripts/derive-starter-ops.ts). Legacy bearer tokens that called in
* the window appear too (they log under their token name) but carry no
* per-client surface row. stdio clients never appear that transport does
* not write mcp_request_log.
*/
export function parseAuthClientsArgs(args: string[]): { usage: boolean; days: number; json: boolean } {
const out = { usage: false, days: 30, json: false };
for (let i = 0; i < args.length; i++) {
const flag = args[i];
if (flag === '--usage') out.usage = true;
else if (flag === '--json') out.json = true;
else if (flag === '--days') {
const v = Number(args[i + 1]);
if (!Number.isInteger(v) || v < 1 || v > 3650) {
throw new Error('--days must be an integer between 1 and 3650');
}
out.days = v;
i++;
} else {
throw new Error(`Unknown flag: ${flag}`);
}
}
return out;
}
interface ClientRow {
client_id: string;
client_name: string | null;
scope: string | null;
surface: string | null;
surface_set_by: string | null;
}
async function clientsCmd(args: string[]) {
const usageLine = 'Usage: auth clients [--usage] [--days N] [--json]';
let parsed: { usage: boolean; days: number; json: boolean };
try {
parsed = parseAuthClientsArgs(args);
} catch (e: any) {
console.error(`Error: ${e.message}`);
console.error(usageLine);
process.exit(1);
}
try {
await withConfiguredSql(async (_sql, engine) => {
// Surface columns land in migration v127; a pre-migration brain still
// gets the listing (surface renders as unknown) instead of an error.
let clients: ClientRow[];
try {
clients = await engine.executeRaw<ClientRow>(
`SELECT client_id, client_name, scope, surface, surface_set_by
FROM oauth_clients ORDER BY client_name, client_id`,
);
} catch {
const bare = await engine.executeRaw<Omit<ClientRow, 'surface' | 'surface_set_by'>>(
`SELECT client_id, client_name, scope FROM oauth_clients ORDER BY client_name, client_id`,
);
clients = bare.map(r => ({ ...r, surface: null, surface_set_by: null }));
}
const { readClientOpUsage } = await import('../core/mcp-usage.ts');
const usage = parsed.usage ? await readClientOpUsage(engine, { days: parsed.days }) : [];
const usageByToken = new Map(usage.map(u => [u.token_name, u]));
const clientIds = new Set(clients.map(c => c.client_id));
const legacyUsage = usage.filter(u => !clientIds.has(u.token_name));
if (parsed.json) {
console.log(JSON.stringify({
window_days: parsed.days,
usage_included: parsed.usage,
clients: clients.map(c => ({
client_id: c.client_id,
client_name: c.client_name,
scopes: c.scope,
surface: c.surface,
surface_set_by: c.surface_set_by,
usage: usageByToken.get(c.client_id) ?? null,
})),
// Legacy bearer tokens seen in the window (no oauth_clients row).
legacy_tokens: legacyUsage,
}, null, 2));
return;
}
if (clients.length === 0 && legacyUsage.length === 0) {
console.log('No OAuth clients registered. Register one: gbrain auth register-client "my-client"');
return;
}
const fmtTop = (u: (typeof usage)[number]) =>
Object.entries(u.ops).slice(0, 5).map(([op, n]) => `${op}(${n})`).join(', ');
for (const c of clients) {
const u = usageByToken.get(c.client_id);
console.log(`${c.client_name ?? '<unnamed>'} (${c.client_id})`);
const surfaceStr = c.surface
? `${c.surface}${c.surface_set_by ? ` (set by ${c.surface_set_by})` : ''}`
: '<server/config resolution>';
console.log(` scopes: ${c.scope ?? '<none>'} surface: ${surfaceStr}`);
if (parsed.usage) {
if (u) {
const auto = u.likely_automation ? ' [automation-shaped: >90% context_pack/delta]' : '';
console.log(` calls (${parsed.days}d): ${u.total_calls} across ${u.distinct_ops.length} ops last seen: ${u.last_seen}${auto}`);
console.log(` top ops: ${fmtTop(u)}`);
} else {
console.log(` calls (${parsed.days}d): 0 (no HTTP MCP calls in window; stdio use is not logged)`);
}
}
console.log('');
}
if (parsed.usage && legacyUsage.length > 0) {
console.log(`Legacy bearer tokens seen in the last ${parsed.days}d (no per-client surface row):`);
for (const u of legacyUsage) {
const auto = u.likely_automation ? ' [automation-shaped]' : '';
console.log(` ${u.token_name}: ${u.total_calls} calls across ${u.distinct_ops.length} ops last seen: ${u.last_seen}${auto}`);
console.log(` top ops: ${fmtTop(u)}`);
}
}
});
} catch (e: any) {
console.error('Error:', e.message);
process.exit(1);
}
}
/**
* Entry point for the `gbrain auth` CLI subcommand. Also reused by the
* direct-script path (see bottom of file) so `bun run src/commands/auth.ts`
* still works.
*/
/**
* Parse `auth create` args into `{ name, takesHolders }`.
* Parse `auth create` args into `{ name, takesHolders, scopes }`.
*
* Exported + pure so the positional-vs-flag logic is unit-testable. Only
* excludes the --takes-holders VALUE from the positional search when the flag
* is present the pre-v0.41 inline version used `rest[takesIdx + 1]` which
* excludes flag VALUES from the positional search when their flag is
* present the pre-v0.41 inline version used `rest[takesIdx + 1]` which
* resolved to `rest[0]` when `takesIdx === -1`, silently dropping the name on
* the bare `gbrain auth create <name>` form.
*
* --scopes accepts comma- and/or whitespace-separated input (the
* register-client #3990 normalization precedent). Validation against the
* allowed scope set happens in create() so the error path exits cleanly.
*/
export function parseAuthCreateArgs(rest: string[]): { name: string; takesHolders?: string[] } {
export function parseAuthCreateArgs(rest: string[]): { name: string; takesHolders?: string[]; scopes?: string[]; error?: string } {
const takesIdx = rest.indexOf('--takes-holders');
const takesHolders = takesIdx >= 0 && rest[takesIdx + 1]
? rest[takesIdx + 1].split(',').map(s => s.trim()).filter(Boolean)
: undefined;
const takesValue = takesIdx >= 0 ? rest[takesIdx + 1] : undefined;
const positional = rest.find(a => !a.startsWith('--') && a !== takesValue);
return { name: positional || '', takesHolders };
// Fail closed on a missing/flag-like value: `--scopes` as the last arg
// silently minting a grandfathered FULL-ACCESS token is the exact
// fail-open-by-silent-precedence class the harness parser rejects [X14].
if (takesIdx >= 0 && (takesValue === undefined || takesValue.startsWith('--'))) {
return { name: '', error: 'the takes-holders flag requires a value (e.g. world,garry)' };
}
const takesHolders = takesValue !== undefined
? takesValue.split(',').map(s => s.trim()).filter(Boolean)
: undefined;
const scopesIdx = rest.indexOf('--scopes');
const scopesValue = scopesIdx >= 0 ? rest[scopesIdx + 1] : undefined;
if (scopesIdx >= 0 && (scopesValue === undefined || scopesValue.startsWith('--'))) {
return { name: '', error: 'the scopes flag requires a value (e.g. read,write) — omitting it would mint a full-access token' };
}
const scopes = scopesValue !== undefined
? scopesValue.split(/[\s,]+/).map(s => s.trim()).filter(Boolean)
: undefined;
const positional = rest.find(a => !a.startsWith('--') && a !== takesValue && a !== scopesValue);
return { name: positional || '', takesHolders, ...(scopes !== undefined ? { scopes } : {}) };
}
export async function runAuth(args: string[]): Promise<void> {
@@ -638,12 +902,21 @@ export async function runAuth(args: string[]): Promise<void> {
switch (cmd) {
case 'create': {
// v0.28: optional --takes-holders world,garry,brain (default: world only)
// #4043: optional --scopes read,write (default: full access, grandfathered)
const parsed = parseAuthCreateArgs(rest);
await create(parsed.name, { takesHolders: parsed.takesHolders });
if (parsed.error) {
console.error(`Error: ${parsed.error}`);
process.exit(1);
}
await create(parsed.name, { takesHolders: parsed.takesHolders, scopes: parsed.scopes });
return;
}
case 'list': await list(); return;
case 'revoke': await revoke(rest[0]); return;
case 'revoke': {
if (rest[0] === '--id') { await revokeById(rest[1] || ''); return; }
await revoke(rest[0]);
return;
}
case 'permissions': {
// gbrain auth permissions <name> set-takes-holders world,garry
await permissions(rest[0] || '', rest[1] || '', rest[2]);
@@ -652,6 +925,7 @@ export async function runAuth(args: string[]): Promise<void> {
case 'register-client': await registerClient(rest[0], rest.slice(1)); return;
case 'rescope-client': await rescopeClient(rest[0], rest.slice(1)); return;
case 'revoke-client': await revokeClient(rest[0]); return;
case 'clients': await clientsCmd(rest); return;
case 'test': {
const tokenIdx = rest.indexOf('--token');
const url = rest.find(a => !a.startsWith('--') && a !== rest[tokenIdx + 1]);
@@ -663,13 +937,17 @@ export async function runAuth(args: string[]): Promise<void> {
console.log(`GBrain Token Management
Usage:
gbrain auth create <name> [--takes-holders world,garry,brain]
gbrain auth create <name> [--takes-holders world,garry,brain] [--scopes read,write]
Create a legacy bearer token. v0.28: --takes-holders
sets the per-token allow-list for the takes.holder
field (default: ["world"]). MCP-bound calls to
takes_list / takes_search / query filter by this.
gbrain auth list List all tokens
gbrain auth revoke <name> Revoke a legacy token
--scopes narrows the token to the listed op scopes
(comma or space separated; omit = full access,
grandfathered).
gbrain auth list List all tokens (id, scopes, usage)
gbrain auth revoke <name> Revoke a legacy token (ALL active rows with that name)
gbrain auth revoke --id <uuid> Revoke exactly one token by id (names are not unique)
gbrain auth permissions <name> set-takes-holders <h1,h2,h3>
Update visibility for an existing token
gbrain auth register-client <name> [options] Register an OAuth 2.1 client (v0.26+)
@@ -701,6 +979,15 @@ Usage:
--source <id> New write source
--federated-read <id1,id2,...> New read-scope source list
--bound-slug-prefixes <p1,p2|none> Replace the slug-prefix write fence ('none' clears it)
--surface <verbs|starter|full|clear> Pin the client's MCP tool surface (operator lock
request_tools cannot override; 'clear' removes the pin
so server/config resolution applies again). Always
bounded by the server's --surface ceiling.
gbrain auth clients [--usage] [--days N] [--json] List OAuth clients with scopes + tool surface. --usage
joins per-client op-call counts, top ops, and last-seen
from mcp_request_log (default 30d window; HTTP clients
only stdio use is not logged). Automation-shaped
clients (>90% context_pack/delta) are flagged.
gbrain auth revoke-client <client_id> Hard-delete an OAuth 2.1 client (cascades to tokens + codes)
gbrain auth test <url> --token <token> Smoke-test a remote MCP server
`);
+26 -6
View File
@@ -34,8 +34,7 @@ import type { BrainEngine, SourceRow } from '../core/engine.ts';
import type { MinionQueue } from '../core/minions/queue.ts';
import { NON_GLOBAL_PHASES, GLOBAL_PHASES, LAST_GLOBAL_AT_KEY } from '../core/cycle.ts';
import { sourceConfigHasRemoteUrl } from '../core/sources-load.ts';
const FULL_CYCLE_FLOOR_MIN = 60;
import { AUTOPILOT_FULL_CYCLE_FLOOR_MINUTES } from './autopilot-remediation-policy.ts';
// #2194 fix #2: failure cooldown. A source whose autopilot-cycle keeps
// failing/timing-out re-dispatches every tick today (only SUCCESS gates
@@ -81,6 +80,8 @@ export interface FanoutResult {
/** True when this tick fell back to the legacy single-job path
* (no sources rows / engine empty). */
legacy_fallback: boolean;
/** True when every enumerated source is inside the freshness window. */
all_sources_fresh: boolean;
}
/**
@@ -180,7 +181,11 @@ export function readLastFullCycleAt(src: SourceRow): Date | null {
* a brain may have fresh sync but stale extract/embed. The 60-min floor on
* full-cycle is the canonical freshness signal for autopilot dispatch.
*/
export function isSourceStale(src: SourceRow, now = Date.now(), floorMin = FULL_CYCLE_FLOOR_MIN): boolean {
export function isSourceStale(
src: SourceRow,
now = Date.now(),
floorMin = AUTOPILOT_FULL_CYCLE_FLOOR_MINUTES,
): boolean {
const last = readLastFullCycleAt(src);
if (last === null) return true;
const ageMin = (now - last.getTime()) / 60_000;
@@ -328,7 +333,7 @@ export function selectSourcesForDispatch(
sources: SourceRow[],
fanoutMax: number,
now = Date.now(),
floorMin = FULL_CYCLE_FLOOR_MIN,
floorMin = AUTOPILOT_FULL_CYCLE_FLOOR_MINUTES,
recentFailures: Map<string, SourceFailure> = new Map(),
cooldownOpts: CooldownOpts = { baseMin: FAILURE_COOLDOWN_BASE_MIN, capMin: FAILURE_COOLDOWN_CAP_MIN },
): { dispatch: SourceRow[]; skippedFresh: SourceRow[]; skippedCap: SourceRow[]; skippedCooldown: SourceRow[] } {
@@ -406,7 +411,14 @@ export async function dispatchPerSource(
} else {
log(`[dispatch] job #${job.id} autopilot-cycle (legacy single-source)`);
}
return { dispatched: [], skipped_fresh: [], skipped_cap: [], skipped_cooldown: [], legacy_fallback: true };
return {
dispatched: [],
skipped_fresh: [],
skipped_cap: [],
skipped_cooldown: [],
legacy_fallback: true,
all_sources_fresh: false,
};
}
// #2194 fix #2: load recent per-source failures + cooldown knobs so a
@@ -426,7 +438,14 @@ export async function dispatchPerSource(
}
const { dispatch, skippedFresh, skippedCap, skippedCooldown } =
selectSourcesForDispatch(sources, opts.fanoutMax, Date.now(), FULL_CYCLE_FLOOR_MIN, recentFailures, cooldownOpts);
selectSourcesForDispatch(
sources,
opts.fanoutMax,
Date.now(),
AUTOPILOT_FULL_CYCLE_FLOOR_MINUTES,
recentFailures,
cooldownOpts,
);
const dispatched: string[] = [];
for (const src of dispatch) {
@@ -509,6 +528,7 @@ export async function dispatchPerSource(
skipped_cap: skippedCap.map(s => s.id),
skipped_cooldown: skippedCooldown.map(s => s.id),
legacy_fallback: false,
all_sources_fresh: skippedFresh.length === sources.length,
};
}
@@ -0,0 +1,46 @@
export const AUTOPILOT_FULL_CYCLE_FLOOR_MINUTES = 60;
export interface AutopilotRemediationPlanShape {
score: number;
planLength: number;
estimatedSeconds: number;
minutesSinceLastFull: number;
}
/**
* Keep recommendation keys stable for doctor/remediate checkpoints while
* giving Autopilot a fresh single-flight slot on every dispatch interval.
*/
export function autopilotRemediationIdempotencyKey(
recommendationKey: string,
dispatchSlot: string,
): string {
return `${recommendationKey}:autopilot:${dispatchSlot}`;
}
/**
* A full cycle is a freshness invariant, independent of the current score or
* targeted plan. Large/slow/severely degraded plans retain the existing
* hammer behavior before the freshness floor is reached.
*/
export function shouldRunAutopilotFullCycle({
score,
planLength,
estimatedSeconds,
minutesSinceLastFull,
}: AutopilotRemediationPlanShape): boolean {
return minutesSinceLastFull >= AUTOPILOT_FULL_CYCLE_FLOOR_MINUTES
|| planLength > 3
|| estimatedSeconds >= 300
|| score < 70;
}
export function shouldSleepHealthyAutopilot(
score: number,
planLength: number,
minutesSinceLastFull: number,
): boolean {
return score >= 95
&& planLength === 0
&& minutesSinceLastFull < AUTOPILOT_FULL_CYCLE_FLOOR_MINUTES;
}
+126 -53
View File
@@ -19,11 +19,17 @@
import { existsSync, readFileSync, writeFileSync, mkdirSync, appendFileSync, utimesSync, unlinkSync, chmodSync, statSync } from 'fs';
import { setCliExitVerdict } from '../core/cli-force-exit.ts';
import { join, dirname } from 'path';
import { detectExecutionEnvironment } from '../core/execution-env.ts';
import { join, dirname, isAbsolute } from 'path';
import { execSync } from 'child_process';
import type { BrainEngine } from '../core/engine.ts';
import { loadPreferences } from '../core/preferences.ts';
import { loadConfig, loadConfigFileOnly, saveConfig, gbrainPath as gbrainHomePath } from '../core/config.ts';
import {
classifyAutopilotLockHolder,
type AutopilotLockProbeDeps,
isPidAlive,
} from '../core/autopilot-lock.ts';
import { ChildWorkerSupervisor } from '../core/minions/child-worker-supervisor.ts';
import { VERSION } from '../version.ts';
import {
@@ -40,6 +46,11 @@ import { evaluateQuietHours } from '../core/minions/quiet-hours.ts';
import { inspectLock } from '../core/db-lock.ts';
import { registerCleanup } from '../core/process-cleanup.ts';
import { resolveAutopilotDispatchTimeoutMs } from './autopilot-timeout.ts';
import {
autopilotRemediationIdempotencyKey,
shouldRunAutopilotFullCycle,
shouldSleepHealthyAutopilot,
} from './autopilot-remediation-policy.ts';
// Path helpers live in a LEAF core module so other commands (gbrain migrate)
// can read the daemon's state files without importing this one — a dynamic
// import of a command module drags its whole flag surface into the importer's
@@ -139,41 +150,87 @@ function logError(phase: string, e: unknown) {
} catch { /* best-effort */ }
}
/**
* Enumerate %PATH% (Windows) for the gbrain CLI shim, honoring PATHEXT.
*
* On win32 this is the FIRST resolution path (`which` does not exist in
* cmd/PowerShell); resolveGbrainCliPath calls it before the execPath and
* argv[1] fallbacks. Unlike `where`, this NEVER looks at the current
* directory, so a stray gbrain.exe in cwd cannot hijack resolution. Only
* directly spawnable extensions (.exe/.com/.cmd/.bat) are accepted, and
* only regular files - a directory named gbrain.exe cannot shadow a real
* binary. Returns the first existing candidate, or '' when none exists.
*/
export function resolveWindowsCliPath(): string {
const pathext = (process.env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD').split(';');
const pathDirs = (process.env.PATH ?? '').split(';');
for (const dir of pathDirs) {
// Skip empty and relative entries: '.' or 'bin' resolve against the
// current directory, which would reintroduce the cwd-hijack `where`
// has. Only absolute %PATH% entries are trusted.
if (!dir || !isAbsolute(dir)) continue;
for (const ext of pathext) {
// Only directly spawnable types: PATHEXT can also carry .JS/.VBS
// (Windows Script Host), which Bun cannot exec - spawning them fails
// EFTYPE. .CMD/.BAT spawn through the shell; .COM/.EXE direct.
const type = ext.toLowerCase();
if (type !== '.exe' && type !== '.com' && type !== '.cmd' && type !== '.bat') continue;
const candidate = join(dir, 'gbrain' + type);
try {
if (statSync(candidate).isFile()) return candidate;
} catch { /* missing or unreadable - keep looking */ }
}
}
return '';
}
/**
* Resolve the gbrain CLI entrypoint for spawning the worker child.
*
* A .ts source path is never a valid spawn target spawning it fails with
* A .ts source path is never a valid spawn target - spawning it fails with
* EACCES because TypeScript source isn't executable. The canonical install
* puts a shim at `/usr/local/bin/gbrain` (or wherever `which gbrain`
* resolves to) that already wraps the right runtime+entrypoint; prefer it.
*
* Order of resolution:
* 1. `which gbrain` the shim on PATH, canonical for installed builds.
* 1. Platform PATH lookup - `which gbrain` on POSIX; explicit %PATH%
* enumeration (resolveWindowsCliPath) on win32, where `which` does
* not exist (#3793).
* 2. process.execPath if it ends with /gbrain (compiled binary, no shim).
* 3. argv[1] if it ends with /gbrain (e.g., direct invocation of compiled
* binary without PATH). Never .ts source paths.
* 4. Throw with a clear install hint.
*/
export function resolveGbrainCliPath(): string {
try {
// #2747: `env: process.env` is required under Bun. Bun's execSync
// snapshots process.env at Bun's OWN startup, not at call time — a
// runtime PATH mutation (dotenv/config loading, shell-profile sourcing
// in a wrapper, etc.) happening between Bun boot and this call is
// invisible to `which` without explicitly forwarding the current env.
// This is why "which gbrain" succeeds when run standalone (fresh Bun
// process, no prior mutation) but can fail from inside autopilot's own
// process at this exact call site. Same fix already applied to
// detectTini() in spawn-helpers.ts (see its comment) — this call site
// was missed.
const which = execSync('which gbrain', {
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'ignore'],
env: process.env,
}).trim();
if (which) return which;
} catch { /* not on $PATH — fall through */ }
// #3793: `which` does not exist in cmd or PowerShell on Windows, so the
// bun-installed gbrain.exe shim on %PATH% was never found and autopilot
// died with "Could not resolve the gbrain CLI path". `where` would find
// it but has a cwd-hijack; use explicit %PATH% enumeration on win32.
if (process.platform === 'win32') {
const win = resolveWindowsCliPath();
if (win) return win;
} else {
try {
// #2747: `env: process.env` is required under Bun. Bun's execSync
// snapshots process.env at Bun's OWN startup, not at call time - a
// runtime PATH mutation (dotenv/config loading, shell-profile sourcing
// in a wrapper, etc.) happening between Bun boot and this call is
// invisible to `which` without explicitly forwarding the current env.
// This is why "which gbrain" succeeds when run standalone (fresh Bun
// process, no prior mutation) but can fail from inside autopilot's own
// process at this exact call site. Same fix already applied to
// detectTini() in spawn-helpers.ts (see its comment) - this call site
// was missed.
const which = execSync('which gbrain', {
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'ignore'],
env: process.env,
})
.trim()
.split(/\r?\n/, 1)[0];
if (which) return which;
} catch { /* not on $PATH - fall through */ }
}
const exec = process.execPath ?? '';
if (exec.endsWith('/gbrain') || exec.endsWith('\\gbrain.exe')) {
return exec;
@@ -193,24 +250,26 @@ export function resolveGbrainCliPath(): string {
`Debug: PATH=${JSON.stringify(process.env.PATH ?? '')} execPath=${JSON.stringify(exec)} argv1=${JSON.stringify(arg1)}`,
);
}
export function shouldSpawnAutopilotWorker(args: string[]): boolean {
return !args.includes('--no-worker');
}
export function isPidAlive(pid: number): boolean {
if (!Number.isFinite(pid) || pid <= 0) return false;
export { isPidAlive };
export const AUTOPILOT_FOREIGN_PID_TAKEOVER_GRACE_MS = 10 * 60 * 1000;
function autopilotLockAgeMs(lockPath: string): number | null {
try {
process.kill(pid, 0);
return true;
} catch (error: unknown) {
return (error as NodeJS.ErrnoException).code === 'EPERM';
return Date.now() - statSync(lockPath).mtimeMs;
} catch {
return null;
}
}
export function decideLockAcquisition(
lockPath: string,
currentPid: number,
deps: AutopilotLockProbeDeps = {},
): { action: 'acquire' } | { action: 'exit'; holderPid: number } | { action: 'takeover'; reason: string } {
if (!existsSync(lockPath)) return { action: 'acquire' };
@@ -222,10 +281,21 @@ export function decideLockAcquisition(
}
const holderPid = Number.parseInt(raw, 10);
const sameProcess = Number.isFinite(holderPid) && holderPid === currentPid;
const alive = !sameProcess && isPidAlive(holderPid);
const holder = classifyAutopilotLockHolder(holderPid, currentPid, deps);
if (alive) return { action: 'exit', holderPid };
if (holder.state === 'alive-autopilot' || holder.state === 'alive-unknown') {
return { action: 'exit', holderPid };
}
if (holder.state === 'alive-foreign') {
const lockAgeMs = autopilotLockAgeMs(lockPath);
if (lockAgeMs !== null && lockAgeMs >= AUTOPILOT_FOREIGN_PID_TAKEOVER_GRACE_MS) {
return { action: 'takeover', reason: `foreign pid ${raw || '<empty>'} with stale lock` };
}
return { action: 'exit', holderPid };
}
if (holder.state === 'self') {
return { action: 'takeover', reason: `own pid ${raw || '<empty>'}` };
}
return { action: 'takeover', reason: `dead pid ${raw || '<empty>'}` };
}
@@ -829,8 +899,8 @@ export async function runAutopilot(engine: BrainEngine, args: string[]) {
//
// New logic: compute the remediation plan (cheap; no full doctor
// walk), then route to the right level of intervention:
// - Score >= 95 + empty plan: full cycle every 60min (phase-
// coupling exercise), otherwise sleep.
// - Full cycle every 60min regardless of score/plan (phase-
// coupling + freshness invariant); healthy brains sleep before it.
// - Small plan (<=3 steps, <5min): submit individual handlers.
// - Large plan or low score: full autopilot-cycle (the hammer).
//
@@ -1075,16 +1145,16 @@ export async function runAutopilot(engine: BrainEngine, args: string[]) {
const estTotal = plan.reduce((s, r) => s + r.est_seconds, 0);
// Track time since last full cycle for the 60-min floor.
const FULL_CYCLE_FLOOR_MIN = 60;
const minutesSinceLastFull = (Date.now() - lastFullCycleAt) / 60000;
const shouldFullCycle =
(score >= 95 && plan.length === 0 && minutesSinceLastFull >= FULL_CYCLE_FLOOR_MIN) ||
plan.length > 3 ||
estTotal >= 300 ||
score < 70;
const shouldFullCycle = shouldRunAutopilotFullCycle({
score,
planLength: plan.length,
estimatedSeconds: estTotal,
minutesSinceLastFull,
});
const shouldSleep = score >= 95 && plan.length === 0 && minutesSinceLastFull < FULL_CYCLE_FLOOR_MIN;
const shouldSleep = shouldSleepHealthyAutopilot(score, plan.length, minutesSinceLastFull);
if (shouldSleep) {
if (jsonMode) {
@@ -1135,7 +1205,11 @@ export async function runAutopilot(engine: BrainEngine, args: string[]) {
if (jsonMode) process.stderr.write(JSON.stringify({ event: 'global_maintenance_dispatch_failed', error: e instanceof Error ? e.message : String(e) }) + '\n');
}
}
if (result.dispatched.length > 0 || result.legacy_fallback) {
// On restart the process-local clock starts overdue. If persisted
// source timestamps say every source is fresh, advance the local
// clock too; otherwise a non-empty targeted plan would be skipped
// on every tick until the persisted 60-minute window elapsed.
if (result.dispatched.length > 0 || result.legacy_fallback || result.all_sources_fresh) {
lastFullCycleAt = Date.now();
}
if (jsonMode) {
@@ -1159,15 +1233,17 @@ export async function runAutopilot(engine: BrainEngine, args: string[]) {
}
} else {
// Small targeted plan — submit individual handlers per step.
// D9 content-hash idempotency keys (from computeRecommendations).
// maxWaiting:1 per submit per codex #17 (closes the backpressure
// gap the prior implementation had for targeted submits).
// Recommendation keys stay stable for doctor/remediate checkpoints;
// Autopilot adds the dispatch interval so completed rows cannot hold
// the remediation slot forever (#4046).
// maxWaiting:1 per submit per codex #17 bounds the cross-window
// backlog if a targeted handler runs longer than one interval.
for (const step of plan) {
try {
const isProtected = !!step.protected;
const submitOpts = {
queue: 'default',
idempotency_key: step.idempotency_key,
idempotency_key: autopilotRemediationIdempotencyKey(step.idempotency_key, slot),
max_attempts: 2,
timeout_ms: timeoutMs,
maxWaiting: 1,
@@ -1399,13 +1475,10 @@ export type InstallTarget = 'macos' | 'linux-systemd' | 'ephemeral-container' |
export function detectInstallTarget(): InstallTarget {
if (process.platform === 'darwin') return 'macos';
const ephemeral = !!(
process.env.RENDER
|| process.env.RAILWAY_ENVIRONMENT
|| process.env.FLY_APP_NAME
|| existsSync('/.dockerenv')
);
if (ephemeral) return 'ephemeral-container';
// Shared detector (execution-env.ts): covers the original Render/Railway/
// Fly//.dockerenv signals AND the cloud-sandbox signature — both get the
// start-script treatment here (no reliable scheduler in either).
if (detectExecutionEnvironment() !== 'local') return 'ephemeral-container';
if (existsSync('/run/systemd/system')) {
try {
+429 -41
View File
@@ -34,6 +34,7 @@ import { VERSION } from '../version.ts';
import { loadConfig, loadConfigFileOnly, toEngineConfig } from '../core/config.ts';
import { createEngine } from '../core/engine-factory.ts';
import { resolveGbrainHome } from '../core/gbrain-home.ts';
import { detectExecutionEnvironment } from '../core/execution-env.ts';
import { realpathOrResolve } from '../core/path-confine.ts';
import { loadQuestionBank } from '../core/bootstrap/assets.ts';
import {
@@ -56,15 +57,28 @@ import {
registerClaudeMcp,
registerCodexMcp,
writeClaudeHooks,
writeCommittedClaudeHooks,
removeClaudeHooks,
} from '../core/bootstrap/hooks.ts';
import {
guardReceiptOverwrite,
readHarnessReceiptState,
readManifest,
readReceipt,
writeReceipt,
type InstallReceipt,
} from '../core/bootstrap/format.ts';
import {
applyHarness,
codexBlockOwnsName,
ensureHarnessHome,
parseHarnessArgs,
removeHarness,
statusHarness,
type HarnessDeps,
} from '../core/bootstrap/harness.ts';
import { codexConfigPath } from '../core/bootstrap/host-specs.ts';
import { promptLine } from '../core/cli-util.ts';
import {
appendInstallLog,
gitOriginUrl,
@@ -72,7 +86,7 @@ import {
statusReport,
type StatusReport,
} from '../core/bootstrap/status.ts';
import { verifyWorkspace } from '../core/bootstrap/verify.ts';
import { verifyWorkspace, deriveWorkspaceSourceId } from '../core/bootstrap/verify.ts';
export const BOOTSTRAP_HELP = `gbrain bootstrap — paste-in agent install (Claude Code / Codex)
@@ -100,6 +114,19 @@ Subcommands (run \`gbrain bootstrap status\` first — it is the resume entrypoi
verify [--json] The whole install contract (round-trip, graph floor,
magic moment, scans, hooks smoke). Exit 0 or not done.
attach [--harness H] Machine two: adopt a cloned agent workspace.
harness [--harness claude-code|codex|all] [--url U | --port N] [--source ID]
[--token-name NAME | --token TOK] [--name MCPNAME] [--project DIR]...
[--no-hooks] [--no-capture] [--force] [--status] [--remove] [--yes] [--json]
Wire framework-spawned Claude Code / Codex sessions to a
RUNNING \`gbrain serve --http\` on this box (#4043): scoped
bearer token, user-scope MCP + headless pre-approval,
lifecycle hooks (user scope, or per --project dir), codex
config block. No agent.json needed. Idempotent; --remove
tears it down. (--local is an accepted no-op alias.)
cloud-setup-script Print the paste-ready cloud environment setup
script (installs the gbrain binary into the
environment snapshot; npm-based bun fetching
is proxy-incompatible in cloud sandboxes).
uninstall [--delete-brain] [--home <dir>] [--yes]
Receipt-keyed removal. The repo stays yours.
@@ -110,6 +137,60 @@ Env: GBRAIN_BOOTSTRAP_ABORT_AFTER=<phase> (test seam — abort after that phase'
const SUPPORT_HINT =
'If you are stuck: run `gbrain bootstrap status --json` and relay the "support" block verbatim.';
/**
* Per-subcommand `--help`/`-h`/`help` usage text for the subcommands that
* MUTATE state (create a repo, register MCP/hooks, run the verify contract,
* adopt a workspace, remove receipt-tracked paths, record an interview
* answer). `runBootstrap`'s dispatch checks `args[0]` for top-level help
* (`--help`/`-h`/`help`/no args), but a help token AFTER the subcommand name
* (e.g. `gbrain bootstrap repo --help`, `gbrain bootstrap uninstall help`)
* previously fell straight into the subcommand's own arg parsing, which had
* no help handling of its own so it ran the real mutation instead of
* printing help. `status`/`cloud-setup-script` are pure reads, so they don't
* need a guard.
*/
const SUBCOMMAND_HELP: Record<string, string> = {
render:
'gbrain bootstrap render [--force] [--only F] [--minimal]\n' +
' Render identity files from the confirmed interview answers. Never clobbers; --force backs up first.',
repo:
'gbrain bootstrap repo\n' +
' Create the dedicated PRIVATE GitHub repo (or adopt an EMPTY private repo you created\n' +
' under your own account), verify the privacy bit via the API, push.',
hooks:
'gbrain bootstrap hooks [--harness claude-code|codex] [--repair] [--no-hooks] [--gbrain-bin <path>]\n' +
' Register MCP (+ per-turn hooks on Claude Code, ON by default; --no-hooks opts out).',
verify:
'gbrain bootstrap verify [--json]\n' +
' The whole install contract (round-trip, graph floor, magic moment, scans, hooks smoke). Exit 0 or not done.',
attach:
'gbrain bootstrap attach [--harness H]\n' +
' Machine two: adopt a cloned agent workspace.',
uninstall:
'gbrain bootstrap uninstall [--delete-brain] [--home <dir>] [--yes]\n' +
' Receipt-keyed removal. The repo stays yours.',
interview:
'gbrain bootstrap interview --init | --set KEY "value" | --skip KEY | --status | --show | --confirm <hash>\n' +
' Create/record/read interview state. See `gbrain bootstrap --help` for the per-flag description.',
};
/**
* `--help`/`-h` are always recognized. The bare word `help` (no dashes) is
* ALSO recognized for every subcommand above EXCEPT `interview` mirroring
* the top-level `sub === 'help'` handling for a user who tries the same
* spelling after a subcommand name. `interview` is excluded from the
* bare-word form because its `--set KEY "value"` free-text answers could
* legitimately BE the literal word "help" (e.g. a one-word answer); none of
* the other subcommands' flags take arbitrary prose, only booleans, enums,
* or paths, so the bare-word collision risk there is negligible (matches the
* already-accepted low-impact risk of `-h` colliding with a literal path
* value like `--home -h`).
*/
function hasHelpToken(args: string[], allowBareWord: boolean): boolean {
if (args.includes('--help') || args.includes('-h')) return true;
return allowBareWord && args.includes('help');
}
/** Thrown by the A7 abort seam; mapped to exit 130 (simulated kill). */
export class BootstrapAbortInjected extends Error {
constructor(phase: string) {
@@ -148,6 +229,19 @@ function resolveWorkspace(args: string[]): string {
return ws ? resolve(ws) : process.cwd();
}
/**
* POSIX single-quote anything not already shell-safe, for commands printed
* as copy/paste guidance (mirror of the private `shellQuote` in
* core/bootstrap/hooks.ts, core/sources-ops.ts, and commands/connect.ts
* same contract: `$()`/backticks in a value are inert literals once quoted).
* A workspace path containing a space or shell metacharacter must not turn
* "the exact command to run" into a broken (or, pasted blind, dangerous) one.
*/
function shellQuoteForDisplay(arg: string): string {
if (/^[A-Za-z0-9_.:/@=-]+$/.test(arg)) return arg;
return `'${arg.replace(/'/g, "'\\''")}'`;
}
// ── Shared plumbing ─────────────────────────────────────────────────────────
type Harness = 'claude-code' | 'codex';
@@ -356,7 +450,7 @@ async function runStatus(ws: string, rest: string[], home: string): Promise<numb
if (report.next) {
console.log(`\nNext: ${report.next}`);
} else {
console.log('\nAll phases done. Weekly self-check: `gbrain bootstrap verify`.');
console.log('\nAll phases done. Weekly self-check: `gbrain bootstrap verify` (close agent sessions first — PGLite is single-writer).');
}
if (report.runbookSkew) {
console.log(
@@ -383,6 +477,15 @@ async function runStatus(ws: string, rest: string[], home: string): Promise<numb
return 0;
}
/** One copy of the A8 invalidation warning shared by --set and --skip so
* the operator-facing instructions cannot drift between the two branches. */
function warnInvalidatedConfirmation(): void {
console.error(
'note: this change voided the prior confirmation — read the full answer set back ' +
'to the human, then `gbrain bootstrap interview --confirm <hash>` again before render.',
);
}
async function runInterview(ws: string, rest: string[]): Promise<number> {
if (rest.includes('--init')) {
const r = initState(ws);
@@ -424,6 +527,7 @@ async function runInterview(ws: string, rest: string[]): Promise<number> {
console.log(`${key}: routed to the 0600 config file (${routed.configKey}). Not recorded in interview state.`);
return 0;
}
if (r.invalidatedConfirmation) warnInvalidatedConfirmation();
console.log(`${key} recorded.`);
return 0;
}
@@ -439,6 +543,7 @@ async function runInterview(ws: string, rest: string[]): Promise<number> {
console.error(r.message);
return 1;
}
if (r.invalidatedConfirmation) warnInvalidatedConfirmation();
console.log(`${key} skipped.`);
return 0;
}
@@ -607,37 +712,57 @@ async function runRepo(ws: string, rest: string[], home: string, runner: ExecRun
abortIfInjected('repo');
// PERSIST_CRON consent [D3 resolved]: opt-in 15-min scan-gated push job
// via the existing sources-harden machinery. Best-effort: a harden
// failure never fails the repo phase — the SessionEnd push backstop is
// always on.
// PERSIST_CRON consent [D3 resolved]: opt-in background persistence via
// the existing sources-harden machinery — a git post-commit auto-push plus
// a 30-minute scheduled pull that keeps multi-machine checkouts fresh
// (honest copy [D9]: the event-driven pushes do the durability work; the
// timer is the freshener). Best-effort: a harden failure never fails the
// repo phase — the per-turn and session-end pushes are always on.
const persist = (consentAnswer(ws, 'PERSIST_CRON') ?? 'no').toLowerCase();
const envKind = detectExecutionEnvironment();
if (persist === 'yes') {
try {
const { hardenBrainRepo } = await import('../core/brain-repo-durability.ts');
const state = readManifest(ws);
const sourceId = state.state === 'initialized' ? state.manifest.source_id : 'workspace';
// Containers/cloud sandboxes have no reliable scheduler — install the
// container-friendly half (post-commit hook + helper) and say so,
// instead of failing a crontab write that could never survive anyway.
const installCron = envKind === 'local';
const report = await hardenBrainRepo({
repoPath: ws,
sourceId,
installCron: true,
installCron,
verify: false,
logger: (l: string) => process.stderr.write(`[harden] ${l}\n`),
});
const attention = report.steps.filter((s) => s.status === 'needs_attention');
console.log(
attention.length === 0
? 'background persistence enabled (15-min scan-gated push job installed).'
: `background persistence partially enabled — needs attention: ${attention.map((s) => `${s.step}: ${s.detail}`).join('; ')}`,
);
if (attention.length > 0) {
console.log(
`background persistence partially enabled — needs attention: ${attention.map((s) => `${s.step}: ${s.detail}`).join('; ')}`,
);
} else if (installCron) {
console.log(
'background persistence enabled (post-commit auto-push + 30-min scheduled pull installed).',
);
} else {
console.log(
`background persistence enabled for this ${envKind === 'cloud-sandbox' ? 'cloud sandbox' : 'container'}: ` +
'post-commit auto-push installed; per-turn and session-end pushes are already on. ' +
'No scheduler exists in this environment, so the 30-min pull is skipped — run ' +
'`gbrain sources harden` on a persistent machine to add it.',
);
}
} catch (e) {
console.error(
`note: background-persistence install failed (${(e as Error).message}). ` +
'Session-end pushes still persist your work; re-try later with `gbrain sources harden`.',
'Per-turn and session-end pushes still persist your work; re-try later with `gbrain sources harden`.',
);
}
} else {
console.log('background persistence declined — the session-end push remains the persistence backstop.');
console.log(
'background persistence declined — the per-turn and session-end pushes remain the persistence backstop.',
);
}
return 0;
});
@@ -708,9 +833,62 @@ async function runHooks(ws: string, rest: string[], home: string, runner: ExecRu
const hooksConsent = !noHooks && (consentAnswer(ws, 'HOOKS_CONSENT') ?? 'yes').toLowerCase() === 'yes';
const gbrainHome = process.env.GBRAIN_HOME?.trim() || undefined;
// One owner per codex server name: if the harness lane's managed TOML block
// owns [mcp_servers.gbrain], this stdio registration must not fight it —
// the FIX7 mismatch path would `codex mcp remove` the harness's server and
// strand orphan marker comments (#4043 ownership rule).
if (harness === 'codex' && codexBlockOwnsName(codexConfigPath(), 'gbrain')) {
console.log(
"the 'gbrain' codex MCP server is managed by `gbrain bootstrap harness` (marker block in the codex " +
'config) — skipping the stdio registration. Run `gbrain bootstrap harness --remove` first if you ' +
'want this workspace-lane stdio registration instead.',
);
return 0;
}
return withLock(ws, async () => {
// 0. source_id visibility seam: `hooks` is the last ENGINE-FREE phase
// before `verify` (which alone can detect a source_id collision — the
// sources registry lives only in the DB). Without this, a human who
// hand-registers a source before verify has no way to know the exact id
// the workspace expects, guesses an "intuitive" name instead, and only
// discovers the mismatch via a `verify` roundtrip FK error — then, after
// switching to the manifest's id, an `overlapping_path` error from the
// earlier guess still claiming the same brain/ dir. Printing the current
// id (and the collision-fallback id verify would derive, a pure path
// hash that needs no engine) up front — plus creating brain/ so
// registration can happen immediately — collapses that multi-round-trip
// loop to one command.
const brainDir = join(ws, 'brain');
mkdirSync(brainDir, { recursive: true });
// --force: brain/ was just created empty — `sources add` fail-fasts on a
// --path that exists but isn't a git repo with committed, tracked
// content (#2707), and gbrain deliberately never auto-git-inits a --path
// source itself (a --path source is the user's own directory — the
// consent boundary #2967 established for sync-time self-heal applies
// here too). --force is the sanctioned opt-in for exactly this "register
// before git-init exists" case (see sources-ops.ts's own not_a_git_repo
// message), and it is safe here because brainDir is not an arbitrary
// user path — it is the fixed `<workspace>/brain` subdir this phase just
// created. Without --force, the printed command below would itself throw
// not_a_git_repo the instant it's pasted.
const quoted = shellQuoteForDisplay(brainDir);
console.log(
`brain source: register this workspace's brain/ now if you haven't — ` +
`\`gbrain sources add ${sourceId} --path ${quoted} --force\` (brain/ is freshly created and empty; ` +
`--force is the documented opt-in for registering before git-init exists). If '${sourceId}' is ` +
`already claimed by a different checkout on this brain, \`gbrain bootstrap verify\` will detect the ` +
`collision and switch this workspace to '${deriveWorkspaceSourceId(ws)}' — re-run the same command ` +
`with that id instead.`,
);
// 1. MCP registration — argv built by the host-format module, executed
// through the runner seam, recorded on the receipt.
// A missing host binary (exit 127) skips MCP registration but NOT the
// hooks below — hooks only write .claude/settings.local.json and need no
// binary. The old early-return silently dropped hooks while the copy said
// only "MCP registration skipped".
let mcpSkipped = false;
const argvs =
harness === 'claude-code'
? registerClaudeMcp({ gbrainBin, scope: mcpScope, sourceId, ...(gbrainHome ? { gbrainHome } : {}) })
@@ -720,10 +898,12 @@ async function runHooks(ws: string, rest: string[], home: string, runner: ExecRu
const res = await runner(argv);
if (res.code === 127) {
console.error(
`\`${argv[0]}\` is not on PATH — is ${harness} installed? MCP registration skipped; ` +
`re-run \`gbrain bootstrap hooks --harness ${harness}\` once it is.`,
`\`${argv[0]}\` is not on PATH — is ${harness} installed? MCP registration skipped ` +
`(per-turn hooks still install below); re-run ` +
`\`gbrain bootstrap hooks --harness ${harness}\` once it is.`,
);
return 2;
mcpSkipped = true;
break;
}
if (res.code !== 0) {
const already = /already exists|already registered/i.test(res.stderr + res.stdout);
@@ -741,12 +921,45 @@ async function runHooks(ws: string, rest: string[], home: string, runner: ExecRu
console.error(
`existing '${mcpName}' MCP registration targets a DIFFERENT workspace/binary — replacing it.`,
);
await runner([argv[0], 'mcp', 'remove', mcpName]);
// The add above failed "already exists" in the CURRENT scope, so the
// blocker lives there — target the remove at that scope on Claude
// Code (a scope-less remove can resolve to a different scope's
// registration and leave the blocker in place). Codex has no scope
// flag. Fail loud if the remove doesn't land: the silent no-op loop
// used to re-fail the add and report nothing actionable.
const rmArgv =
harness === 'claude-code'
? [argv[0], 'mcp', 'remove', mcpName, '--scope', mcpScope]
: [argv[0], 'mcp', 'remove', mcpName];
const rm = await runner(rmArgv);
if (rm.code !== 0) {
console.error(
`\`${rmArgv.join(' ')}\` failed (${rm.stderr.trim() || `exit ${rm.code}`}) — remove the stale ` +
`registration by hand (\`${argv[0]} mcp get ${mcpName}\` shows where it lives), then re-run ` +
`\`gbrain bootstrap hooks --harness ${harness} --repair\`.`,
);
return 1;
}
const re = await runner(argv);
if (re.code !== 0 && !/already exists|already registered/i.test(re.stderr + re.stdout)) {
console.error(`MCP re-registration failed (${argv.join(' ')}): ${re.stderr.trim() || `exit ${re.code}`}`);
return 1;
}
// Re-add can itself return "already exists" if a racing writer
// re-claimed the name between our remove and add — that registration
// is NOT ours. Re-verify and abort rather than bless a foreign
// endpoint that would intercept memory ops. (Only the recorded
// warn-then-continue step-2 smoke did this before; here it's fatal.)
const post = await verifyMcpTargetsWorkspace(runner, harness, mcpName, gbrainBin, sourceId);
if (post === 'mismatch') {
console.error(
`after replacing '${mcpName}', it STILL targets a different workspace/binary — ` +
`refusing to continue (a racing registration may have re-claimed the name). ` +
`Inspect \`${argv[0]} mcp get ${mcpName}\`, remove it by hand, then re-run ` +
`\`gbrain bootstrap hooks --harness ${harness} --repair\`.`,
);
return 1;
}
} else {
console.log(
`MCP server '${mcpName}' already registered — could not confirm it targets this workspace ` +
@@ -759,7 +972,7 @@ async function runHooks(ws: string, rest: string[], home: string, runner: ExecRu
// 2. Registration smoke [FIX7]: confirm the EXPECTED server (binary path +
// GBRAIN_SOURCE), not merely a 'gbrain' substring in `mcp list`. Falls back
// to the list probe only when the host has no `mcp get`.
try {
if (!mcpSkipped) try {
const listBin = harness === 'claude-code' ? 'claude' : 'codex';
const scopeLabel = harness === 'claude-code' ? mcpScope : 'user-global';
const verdict = await verifyMcpTargetsWorkspace(runner, harness, 'gbrain', gbrainBin, sourceId);
@@ -789,12 +1002,40 @@ async function runHooks(ws: string, rest: string[], home: string, runner: ExecRu
let hooksWritten = false;
if (harness === 'claude-code') {
if (hooksConsent) {
const r = writeClaudeHooks(ws, {
gbrainBin,
env: { GBRAIN_SOURCE: sourceId, ...(gbrainHome ? { GBRAIN_HOME: gbrainHome } : {}) },
});
// Carrier choice [D12]: cloud sandboxes clone fresh and snapshot hook
// config at session start — only the repo-COMMITTED settings file
// exists there, so cloud installs write the committed carrier
// (PATH-resolved, fail-open commands; no machine paths). Local
// installs keep the gitignored settings.local.json with the absolute
// binary path. The writers enforce that one event never fires from
// both files.
const hookEnv = { GBRAIN_SOURCE: sourceId, ...(gbrainHome ? { GBRAIN_HOME: gbrainHome } : {}) };
const cloudCarrier = detectExecutionEnvironment() === 'cloud-sandbox';
let r: ReturnType<typeof writeClaudeHooks> | ReturnType<typeof writeCommittedClaudeHooks>;
try {
r = cloudCarrier
? writeCommittedClaudeHooks(ws, { env: hookEnv })
: writeClaudeHooks(ws, { gbrainBin, env: hookEnv });
} catch (e) {
// Fail-closed on an unparseable settings file (either carrier): MCP
// (step 1) still landed; record that, surface the fix, and exit
// nonzero so the paste-in flow knows hooks are NOT installed.
console.error((e as Error).message);
if (!mcpSkipped) {
appendReceiptRegistration(home, ws, { host: harness, scope: mcpScope, detail: 'mcp' });
}
return 1;
}
hooksWritten = true;
console.log(`hooks installed (${r.installed.length} event(s)) in ${r.settingsPath}${repair ? ' [repair]' : ''} — your brain now loads every turn. Turn off any time with GBRAIN_HOOKS=0, or re-run with --no-hooks.`);
console.log(
`hooks installed (${r.installed.length} event(s)) in ${r.settingsPath}${repair ? ' [repair]' : ''} — your brain now loads every turn. Turn off any time with GBRAIN_HOOKS=0, or re-run with --no-hooks.`,
);
if (cloudCarrier) {
console.log(
'cloud sandbox: hooks written to the COMMITTED .claude/settings.json (fail-open, PATH-resolved) — ' +
'commit + push it so the next session starts with hooks live; hooks written mid-session activate on the NEXT session (startup snapshot).',
);
}
for (const note of r.notes) console.error(note);
} else {
console.log(
@@ -804,18 +1045,21 @@ async function runHooks(ws: string, rest: string[], home: string, runner: ExecRu
);
}
} else {
console.log('Codex has no hook system — per-turn context is the AGENTS.md pull protocol (stated plainly, not a bug).');
console.log('gbrain does not wire Codex hooks yet — per-turn context is the AGENTS.md pull protocol (stated plainly; the codex hook lane is a filed follow-up).');
}
// 4. Receipt registration record [CX2-12].
appendReceiptRegistration(home, ws, {
host: harness,
scope: harness === 'claude-code' ? mcpScope : 'user',
detail: hooksWritten ? 'mcp+hooks' : 'mcp',
});
// 4. Receipt registration record [CX2-12]. Detail records what actually
// landed; nothing landed at all (127 + no hooks) → no receipt entry.
if (!mcpSkipped || hooksWritten) {
appendReceiptRegistration(home, ws, {
host: harness,
scope: harness === 'claude-code' ? mcpScope : 'user',
detail: hooksWritten ? (mcpSkipped ? 'hooks' : 'mcp+hooks') : 'mcp',
});
}
abortIfInjected('wire');
return 0;
return mcpSkipped ? 2 : 0;
});
}
@@ -837,7 +1081,7 @@ async function runVerify(ws: string, rest: string[], home: string): Promise<numb
const sourceId = state.state === 'initialized' ? state.manifest.source_id : 'workspace';
const result = await verifyWorkspace(engine, ws, { sourceId, gbrainHomeDir: home });
if (jsonMode) {
console.log(JSON.stringify({ ok: result.ok, checks: result.checks, capability: result.capability, tour: result.tour }, null, 2));
console.log(JSON.stringify({ ok: result.ok, checks: result.checks, capability: result.capability, tour: result.tour, handoff: result.handoff }, null, 2));
} else {
console.log(result.report);
}
@@ -901,11 +1145,74 @@ export function workspaceBrainStats(ws: string): { sources: string[]; pages: num
return { sources, pages };
}
/** `gbrain bootstrap harness` (#4043) machine-level, no workspace, no
* agent.json. Locks on the gbrain HOME (there is no workspace to lock). */
async function runHarness(rest: string[], home: string, runner: ExecRunner): Promise<number> {
const flags = parseHarnessArgs(rest);
if (flags.error) {
console.error(flags.error);
return 2;
}
const deps: HarnessDeps = {
runner,
gbrainHome: home,
// Fallback only — the flag itself is parsed (and error-checked) once, by
// parseHarnessArgs; flags.gbrainBin wins inside applyHarness.
gbrainBin: resolveGbrainBin(),
isTTY: process.stdout.isTTY === true,
prompt: promptLine,
};
// [X12] --status is READ-ONLY: no home mkdir, no lock — it must work (and
// stay side-effect-free) even while an apply/remove holds the mutex.
if (flags.status) {
return statusHarness(flags, deps);
}
ensureHarnessHome(home);
return withLock(home, async () => {
if (flags.remove) {
const code = await removeHarness(flags, deps);
abortIfInjected('harness');
return code;
}
const code = await applyHarness(flags, deps);
abortIfInjected('harness');
return code;
});
}
async function runUninstall(ws: string, rest: string[], home: string, runner: ExecRunner): Promise<number> {
const deleteBrain = rest.includes('--delete-brain');
const yes = rest.includes('--yes');
const homeFlag = flagValue(rest, '--home');
const effectiveHome = homeFlag ? resolve(homeFlag) : home;
return withLock(ws, async () => {
// The HOME lock (runHarness's mutex) is held across the ENTIRE uninstall
// body — not just the harness-removal step — so a concurrent
// `bootstrap harness` apply can never mint+wire in the window between
// harness removal and the workspace teardown's rm of <home>/bootstrap
// (which would strand a fresh receipt + live wiring). Consistent order
// (ws → home), distinct dirs, so no deadlock; same-dir configs skip the
// nested acquire (the lock is non-reentrant).
const body = async (): Promise<number> => {
// Harness wiring is removed FIRST (#4043 ordering, load-bearing twice
// over: the token revoke needs the DB alive, and --delete-brain rmSyncs
// <home>/bootstrap — which would destroy harness.json unconsumed).
const harnessState = readHarnessReceiptState(effectiveHome);
let harnessRemoved = false;
if (harnessState.state !== 'absent') {
console.log('harness wiring detected — removing it first (token revoke needs the brain alive).');
const flags = parseHarnessArgs(['--remove', ...(yes ? ['--yes'] : [])]);
const code = await removeHarness(flags, { runner, gbrainHome: effectiveHome });
if (code !== 0) {
console.error(
'harness removal did not fully converge — stopping BEFORE workspace teardown so the harness ' +
'receipt is never stranded. Fix the reported issue (or stop the live serve) and re-run.',
);
return 1;
}
harnessRemoved = true;
}
if (deleteBrain) {
// Facts-export offer BEFORE any deletion can run — facts are user
// knowledge, not derived state; after the rm there is nothing to export.
@@ -913,13 +1220,35 @@ async function runUninstall(ws: string, rest: string[], home: string, runner: Ex
'offered: export facts before deletion (`gbrain facts export`) — the brain DB is about to be removed and facts are not derived state',
);
}
const result = await uninstallWorkspace(ws, {
deleteBrain,
...(yes ? { confirm: async () => true } : {}),
gbrainHomeDir: homeFlag ? resolve(homeFlag) : home,
homeExplicit: homeFlag !== undefined,
brainStats: async () => workspaceBrainStats(ws),
});
// Read the source id BEFORE uninstallWorkspace removes rendered files —
// the durability teardown below needs it and the manifest may not survive.
const preState = readManifest(ws);
const durabilitySourceId = preState.state === 'initialized' ? preState.manifest.source_id : 'workspace';
let result;
try {
result = await uninstallWorkspace(ws, {
deleteBrain,
...(yes ? { confirm: async () => true } : {}),
gbrainHomeDir: effectiveHome,
homeExplicit: homeFlag !== undefined,
brainStats: async () => workspaceBrainStats(ws),
});
} catch (e) {
// A harness-only box has machine-level wiring but no workspace install:
// the pre-teardown refusals that mean "this workspace isn't the
// bootstrapped one" end the run as success once harness removal ran.
// LIVE_SERVE and everything else stay hard refusals.
if (
harnessRemoved &&
e instanceof BootstrapError &&
(e.code === 'NO_RECEIPT' || e.code === 'HOME_GUARD' || e.code === 'RECEIPT_MISMATCH')
) {
console.log(`no workspace install on this machine (naming the refusal: ${e.code}); harness wiring removed.`);
abortIfInjected('uninstall');
return 0;
}
throw e;
}
// Execute the structured host-registration removals the module returned.
for (const reg of result.registration_removals) {
@@ -935,6 +1264,41 @@ async function runUninstall(ws: string, rest: string[], home: string, runner: Ex
}
}
// Per-root hook-lane state [D13]: remove this workspace's push-status,
// debounce, and announce files — a dead root's failing record would
// otherwise re-fire the failure banner forever (it can never be cleared
// by a re-push once the workspace is gone).
try {
const { pushStatusPathForRoot, workspaceRootHash } = await import('../core/workspace-push.ts');
const { execFileSync } = await import('node:child_process');
let root = ws;
try {
root = execFileSync('git', ['-C', ws, 'rev-parse', '--show-toplevel'], {
stdio: ['ignore', 'pipe', 'ignore'], timeout: 5_000, env: process.env,
}).toString().trim() || ws;
} catch { /* not a repo — use ws as-is */ }
const { rmSync } = await import('node:fs');
const statusFile = pushStatusPathForRoot(root);
for (const f of [statusFile, `${statusFile}.announced`, join(resolveGbrainHome(), 'bootstrap', `stop-push-${workspaceRootHash(root)}.json`)]) {
rmSync(f, { force: true });
}
} catch { /* best-effort */ }
// Durability teardown [B6]: uninstall previously left the launchd/cron
// job, the untracked post-commit hook, and the credential wiring behind.
// Best-effort — a teardown hiccup never fails the uninstall. The COMMITTED
// helper script and AGENTS.md rules stay (repo content is the user's).
try {
const { unhardenBrainRepo } = await import('../core/brain-repo-durability.ts');
const steps = await unhardenBrainRepo({ repoPath: ws, sourceId: durabilitySourceId });
const acted = steps.filter((s) => s.status === 'fixed');
if (acted.length > 0) {
console.log(`durability wiring removed: ${acted.map((s) => s.step).join(', ')} (committed helper + AGENTS rules stay — repo content is yours)`);
}
} catch (e) {
console.error(`note: durability teardown incomplete (${(e as Error).message}) — run \`gbrain sources unharden ${durabilitySourceId}\` by hand if a scheduled job lingers.`);
}
// The facts-export offer already printed BEFORE deletion (above); don't
// repeat it after the brain is gone.
for (const step of result.steps) {
@@ -947,6 +1311,8 @@ async function runUninstall(ws: string, rest: string[], home: string, runner: Ex
console.log('The workspace repo and its files remain yours — the body is portable by design.');
abortIfInjected('uninstall');
return 0;
};
return resolve(effectiveHome) === resolve(ws) ? body() : withLock(effectiveHome, body);
});
}
@@ -972,15 +1338,27 @@ export async function runBootstrap(args: string[], opts: RunBootstrapOpts = {}):
const logCtx: LogCtx = { home, ws, ...(harnessForLog ? { harness: harnessForLog } : {}) };
const t0 = Date.now();
const KNOWN = new Set(['status', 'interview', 'render', 'repo', 'hooks', 'verify', 'attach', 'uninstall']);
const KNOWN = new Set(['status', 'interview', 'render', 'repo', 'hooks', 'verify', 'attach', 'uninstall', 'harness', 'cloud-setup-script']);
if (!KNOWN.has(sub)) {
console.error(`unknown subcommand: ${sub}`);
console.error(BOOTSTRAP_HELP);
return 2;
}
// Subcommand-level help: BEFORE any subcommand body runs, so a help token
// after a mutating subcommand (repo/hooks/verify/attach/uninstall/render/
// interview) never falls through into the real operation, regardless of
// what other flags/values precede it in `rest`. No install-log entry
// either — this isn't a phase run.
if (SUBCOMMAND_HELP[sub] && hasHelpToken(rest, sub !== 'interview')) {
console.log(SUBCOMMAND_HELP[sub]);
return 0;
}
// The install log records the PHASE name, and the hooks subcommand is the
// 'wire' phase (status.ts phase list) — one mapping, used at every log site.
// 'harness' is its own log phase (NOT a status.ts phase — that list is
// CI-pinned; install.jsonl phase names are free-form telemetry).
const logPhaseName = sub === 'hooks' ? 'wire' : sub;
try {
@@ -989,6 +1367,13 @@ export async function runBootstrap(args: string[], opts: RunBootstrapOpts = {}):
case 'status':
// status is the read surface — it does not log itself into install.jsonl.
return await runStatus(ws, rest, home);
case 'cloud-setup-script': {
// Pure print [D16]: the paste-ready cloud environment setup script.
// Read surface like status — no install log entry.
const { loadCloudSetupScript } = await import('../core/bootstrap/assets.ts');
console.log(loadCloudSetupScript().trimEnd());
return 0;
}
case 'interview':
code = await runInterview(ws, rest);
break;
@@ -1010,6 +1395,9 @@ export async function runBootstrap(args: string[], opts: RunBootstrapOpts = {}):
case 'uninstall':
code = await runUninstall(ws, rest, home, runner);
break;
case 'harness':
code = await runHarness(rest, home, runner);
break;
default:
return 2; // unreachable
}
+608 -69
View File
@@ -17,19 +17,22 @@
*/
import { spawn } from 'child_process';
import { mkdtempSync, writeFileSync, mkdirSync, rmSync, existsSync } from 'fs';
import { join } from 'path';
import { mkdtempSync, writeFileSync, mkdirSync, rmSync, existsSync, readFileSync, appendFileSync, chmodSync, cpSync, lstatSync } from 'fs';
import { join, resolve, basename, dirname } from 'path';
import { fileURLToPath } from 'url';
import { tmpdir } from 'os';
import { randomBytes } from 'crypto';
import { logFriction, frictionDir } from '../core/friction.ts';
import { logFriction, frictionDir, frictionFile } from '../core/friction.ts';
import { loadScenario, listScenarios, readBrief, type ScenarioConfig } from '../core/claw-test/scenarios.ts';
import { parseProgressEvents, verifyExpectedPhases } from '../core/claw-test/progress-tail.ts';
import { resolveAgentRunner, listRegisteredAgents, registerAgentRunner } from '../core/claw-test/agent-runner.ts';
import { resolveAgentRunner, listRegisteredAgents, registerAgentRunner, validateBinPathEnv } from '../core/claw-test/agent-runner.ts';
import { OpenClawRunner } from '../core/claw-test/runners/openclaw.ts';
import { HermesRunner } from '../core/claw-test/runners/hermes.ts';
import { createTranscriptSink } from '../core/claw-test/transcript-capture.ts';
// Ensure built-in runners are registered.
registerAgentRunner('openclaw', () => new OpenClawRunner());
registerAgentRunner('hermes', () => new HermesRunner());
interface HarnessOpts {
scenario: string;
@@ -38,8 +41,11 @@ interface HarnessOpts {
keepTempdir: boolean;
listAgents: boolean;
help: boolean;
/** Path to the gbrain binary used to invoke child commands. Defaults to argv[0]. */
gbrainBin?: string;
/** Path to the gbrain binary used to invoke child commands (always set by
* parseArgs: GBRAIN_BIN_OVERRIDE when valid; else the compiled gbrain
* binary, or a synthesized launcher when running under the bun runtime
* see resolveGbrainBin). */
gbrainBin: string;
}
interface PhaseOutcome {
@@ -49,10 +55,24 @@ interface PhaseOutcome {
stderrEvents: number;
stdoutTail: string;
stderrTail: string;
/** Full stdout, only populated when invokeGbrain is asked to capture it. */
stdoutFull?: string;
}
const TAIL_BYTES = 4_096;
const SUBPROCESS_TIMEOUT_MS = 5 * 60_000; // 5 minutes per phase
/** Per-phase cap for the harness's own gbrain children (staging, scripted
* phases, oracle probes). Env override is a test/incident escape hatch. */
const SUBPROCESS_TIMEOUT_MS = envTimeoutMs('GBRAIN_CLAW_PHASE_TIMEOUT_MS', 5 * 60_000);
/** Wall clock for the live agent turn real fresh-install turns run long
* (help text promises "5 to 10 min"), so the agent gets double the phase cap. */
const LIVE_AGENT_TIMEOUT_MS = envTimeoutMs('GBRAIN_CLAW_AGENT_TIMEOUT_MS', 10 * 60_000);
function envTimeoutMs(name: string, fallback: number): number {
const raw = process.env[name];
if (!raw) return fallback;
const n = Number(raw);
return Number.isFinite(n) && n > 0 ? n : fallback;
}
export async function runClawTest(args: string[]): Promise<number> {
const opts = parseArgs(args);
@@ -66,6 +86,18 @@ export async function runClawTest(args: string[]): Promise<number> {
return cmdListAgents();
}
// Charset guard: both values flow into filesystem paths (scenario → the
// fixtures root join, agent → the run-id → the tempdir template), so a
// traversal-shaped value would either escape the fixtures root or crash
// sanitizeRunId mid-run. Usage error, exit 2.
const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
for (const [flag, value] of [['scenario', opts.scenario], ['agent', opts.agent]] as const) {
if (!NAME_RE.test(value)) {
console.error(`invalid --${flag} value ${JSON.stringify(value)}: letters, digits, dot, dash, underscore only`);
return 2;
}
}
let scenario: ScenarioConfig;
try {
scenario = loadScenario(opts.scenario);
@@ -83,6 +115,26 @@ export async function runClawTest(args: string[]): Promise<number> {
console.log(`run-id: ${runId}`);
console.log(`tempdir: ${runRoot}`);
// Run-start meta record. Agent-name resolution in `gbrain friction diff`
// depends on this: a fully clean run otherwise writes zero agent-stamped
// entries and could never be resolved by agent name. Uses the existing
// phase-marker kind (no new FrictionKind) + additive scenario/harness_schema
// fields.
const agentLabel = opts.live ? opts.agent : 'scripted';
try {
logFriction({
runId,
phase: 'harness',
kind: 'phase-marker',
marker: 'start',
message: `run start: scenario=${scenario.name} agent=${agentLabel}`,
source: 'harness',
agent: agentLabel,
scenario: scenario.name,
harnessSchema: 1,
});
} catch { /* best effort */ }
// SIGINT/SIGTERM finalization (D11)
let interrupted = false;
const onSignal = () => {
@@ -94,7 +146,7 @@ export async function runClawTest(args: string[]): Promise<number> {
message: 'run interrupted by signal',
kind: 'interrupted',
source: 'harness',
agent: opts.agent,
agent: agentLabel,
});
} catch { /* best effort */ }
};
@@ -108,9 +160,48 @@ export async function runClawTest(args: string[]): Promise<number> {
} else {
exitCode = await runScripted(opts, scenario, { runId, runRoot, gbrainHome });
}
} catch (e) {
// Without this, a thrown run (spawn failure, runner detect race) would
// reach the finally block with exitCode still 0 and stamp a
// `run complete … exit=0` meta record — the friction log (diff/render's
// input) silently recording success for a crashed run.
exitCode = 1;
const msg = e instanceof Error ? e.message : String(e);
console.error(`claw-test: run crashed: ${msg}`);
if (e instanceof Error && e.stack) console.error(e.stack);
try {
logFriction({
runId,
phase: 'harness',
message: `harness crashed: ${msg}`,
severity: 'blocker',
source: 'harness',
agent: agentLabel,
});
} catch { /* best effort */ }
} finally {
process.off('SIGINT', onSignal);
process.off('SIGTERM', onSignal);
// Run-completion meta record (pairs with the start marker above).
try {
logFriction({
runId,
phase: 'harness',
kind: 'phase-marker',
marker: 'end',
message: `run complete: scenario=${scenario.name} agent=${agentLabel} exit=${exitCode}`,
source: 'harness',
agent: agentLabel,
scenario: scenario.name,
});
} catch { /* best effort */ }
// Persist agent/child-side friction BEFORE the tempdir is deleted. The
// children run with GBRAIN_HOME=<runRoot>, so their friction lands under
// <runRoot>/.gbrain/friction/<runId>.jsonl — rmSync below would silently
// destroy it on every run, leaving `friction render`/`diff` with only the
// harness's half of the story. Merge into the parent's friction file
// (same runId; the two sides write disjoint entries).
mergeChildFriction(runRoot, runId);
if (!opts.keepTempdir && !interrupted) {
try { rmSync(runRoot, { recursive: true, force: true }); } catch { /* best effort */ }
} else {
@@ -148,6 +239,36 @@ export async function runClawTest(args: string[]): Promise<number> {
*/
const POSTGRES_POLLUTION_ENV_VARS = ['DATABASE_URL', 'GBRAIN_DATABASE_URL'];
/**
* Child env for gbrain invocations (scripted phases AND live-mode staging /
* oracle probes): parent env minus Postgres-pointing vars AND minus every
* other GBRAIN_* routing/tuning var a stray operator GBRAIN_BRAIN_ID /
* GBRAIN_SOURCE / threshold override would misroute the staging and oracle
* probes and produce false verify verdicts (the same class scripts/run-e2e.sh
* scrubs for e2e hermeticity). The two vars the harness owns are re-applied
* last so a parent override can't win.
*/
function buildChildEnv(ctx: { runId: string; gbrainHome: string }): Record<string, string> {
const parentEnv = process.env as Record<string, string | undefined>;
const childEnv: Record<string, string> = {};
for (const [k, v] of Object.entries(parentEnv)) {
if (v === undefined) continue;
if (POSTGRES_POLLUTION_ENV_VARS.includes(k)) continue;
if (k.startsWith('GBRAIN_')) continue;
childEnv[k] = v;
}
childEnv.GBRAIN_HOME = ctx.gbrainHome;
childEnv.GBRAIN_FRICTION_RUN_ID = ctx.runId;
return childEnv;
}
/** The hermetic run's PGLite path (configDir appends '.gbrain'). One spelling
* for all probe/seed sites a drifted copy would silently probe a
* nonexistent db and fail the upgrade oracle as "unreadable". */
function pgliteDbPath(gbrainHome: string): string {
return join(gbrainHome, '.gbrain', 'brain.pglite');
}
async function runScripted(
opts: HarnessOpts,
scenario: ScenarioConfig,
@@ -157,17 +278,7 @@ async function runScripted(
// The harness is PGLite-only by design; an inherited DATABASE_URL
// would force loadConfig() to flip the engine to 'postgres' at the
// next phase boundary and break the hermetic-tempdir contract.
const parentEnv = process.env as Record<string, string | undefined>;
const childEnv: Record<string, string> = { GBRAIN_HOME: ctx.gbrainHome, GBRAIN_FRICTION_RUN_ID: ctx.runId };
for (const [k, v] of Object.entries(parentEnv)) {
if (v === undefined) continue;
if (POSTGRES_POLLUTION_ENV_VARS.includes(k)) continue;
childEnv[k] = v;
}
// Re-apply the explicit overrides so a parent GBRAIN_HOME / GBRAIN_FRICTION_RUN_ID
// can't accidentally win the merge.
childEnv.GBRAIN_HOME = ctx.gbrainHome;
childEnv.GBRAIN_FRICTION_RUN_ID = ctx.runId;
const childEnv = buildChildEnv(ctx);
const phases: { name: string; argv: string[] }[] = [];
// Phase 2: install_brain. `--no-embedding` defers embedding setup so the
@@ -200,35 +311,51 @@ async function runScripted(
// Phase 6: verify
phases.push({ name: 'verify', argv: ['doctor', '--json', '--progress-json'] });
// Pre-phase: upgrade scenario seeds the database
if (scenario.kind === 'upgrade' && scenario.seedRelative) {
const seedSql = join(scenario.dir, scenario.seedRelative, 'dump.sql');
if (existsSync(seedSql)) {
const dbPath = join(ctx.gbrainHome, '.gbrain', 'brain.pglite');
mkdirSync(join(ctx.gbrainHome, '.gbrain'), { recursive: true });
const { seedPgliteFromFile } = await import('../core/claw-test/seed-pglite.ts');
try {
await seedPgliteFromFile({ dbPath, sqlPath: seedSql });
console.log(`[seed] replayed ${seedSql}${dbPath}`);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
logFriction({
runId: ctx.runId,
phase: 'seed',
message: `seed replay failed: ${msg}`,
severity: 'blocker',
source: 'harness',
agent: opts.agent,
});
return 1;
}
// Pre-phase: upgrade scenario seeds the database. A missing dump is a LOUD
// failure, not a skip: skipping would init a current-version database and
// report a false-green "upgrade" that never exercised a migration.
if (scenario.kind === 'upgrade') {
const seedSql = scenario.seedRelative ? join(scenario.dir, scenario.seedRelative, 'dump.sql') : null;
if (!seedSql || !existsSync(seedSql)) {
const msg = seedSql
? `upgrade scenario has no seed dump at ${seedSql}`
: 'upgrade scenario declares no seed dir';
logFriction({
runId: ctx.runId,
phase: 'seed',
message: msg,
severity: 'blocker',
hint: 'upgrade runs need a real dump.sql to measure the migration (see the TODOS entry for the v0.18 seed dump)',
source: 'harness',
agent: 'scripted',
});
console.error(`[seed] ${msg}`);
return 1;
}
const dbPath = pgliteDbPath(ctx.gbrainHome);
mkdirSync(join(ctx.gbrainHome, '.gbrain'), { recursive: true });
const { seedPgliteFromFile } = await import('../core/claw-test/seed-pglite.ts');
try {
await seedPgliteFromFile({ dbPath, sqlPath: seedSql });
console.log(`[seed] replayed ${seedSql}${dbPath}`);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
logFriction({
runId: ctx.runId,
phase: 'seed',
message: `seed replay failed: ${msg}`,
severity: 'blocker',
source: 'harness',
agent: 'scripted',
});
return 1;
}
}
const allStderr: string[] = [];
const outcomes: PhaseOutcome[] = [];
for (const phase of phases) {
const outcome = await invokeGbrain(opts.gbrainBin ?? 'gbrain', phase.argv, ctx.runRoot, childEnv);
const outcome = await invokeGbrain(opts.gbrainBin, phase.argv, ctx.runRoot, childEnv);
outcome.phase = phase.name;
outcomes.push(outcome);
allStderr.push(outcome.stderrTail);
@@ -240,7 +367,7 @@ async function runScripted(
severity: 'error',
hint: outcome.stderrTail.trim().slice(0, 500),
source: 'harness',
agent: opts.agent,
agent: 'scripted',
});
return 1;
} else {
@@ -251,7 +378,7 @@ async function runScripted(
kind: 'phase-marker',
marker: 'end',
source: 'harness',
agent: opts.agent,
agent: 'scripted',
});
}
}
@@ -268,7 +395,7 @@ async function runScripted(
severity: 'blocker',
hint: 'either the command did not run or it did not emit progress events; check phase log above',
source: 'harness',
agent: opts.agent,
agent: 'scripted',
});
}
return 1;
@@ -281,6 +408,19 @@ async function runScripted(
// Live mode
// ---------------------------------------------------------------------------
/** Per-agent install hints for the agent_detect blocker. */
const AGENT_INSTALL_HINTS: Record<string, string> = {
openclaw: 'install openclaw or set OPENCLAW_BIN',
hermes: 'install hermes (https://hermes-agent.nousresearch.com) or set HERMES_BIN',
};
/**
* Live mode. Hermeticity posture (deliberate, documented): live mode runs the
* OPERATOR's configured agent the real agent home (~/.openclaw, ~/.hermes,
* model settings, skills) is inherited against a HERMETIC BRAIN
* (GBRAIN_HOME=tempdir). The fully hermetic lane is the door e2e
* (install-real-hermes.serial.test.ts), which isolates the agent home too.
*/
async function runLive(
opts: HarnessOpts,
scenario: ScenarioConfig,
@@ -302,17 +442,49 @@ async function runLive(
phase: 'agent_detect',
message: `agent ${opts.agent} not available: ${detected.reason ?? 'unknown'}`,
severity: 'blocker',
hint: opts.agent === 'openclaw' ? 'install openclaw or set OPENCLAW_BIN' : undefined,
hint: AGENT_INSTALL_HINTS[opts.agent],
source: 'harness',
agent: opts.agent,
});
return 2;
}
// ---- Stage the scenario (scenario-driven; mirrors the scripted branch) ----
// The BRIEF's preconditions must actually exist before the agent reads it:
// fresh-install promises "workspace already has an AGENTS.md", "3 small
// markdown pages already there" (./brain), and "user just ran gbrain init".
// Without staging, every live run starts in an empty tempdir and the run
// measures recovery-from-broken-fixture, not gbrain friction.
const childEnv = buildChildEnv(ctx);
const gbrainBin = opts.gbrainBin;
const stageFailed = await stageLiveScenario(opts, scenario, ctx, childEnv, gbrainBin);
if (stageFailed !== 0) return stageFailed;
// Upgrade oracle needs the pre-turn schema version (non-mutating probe —
// any gbrain CLI connect would auto-apply migrations and do the agent's
// work for it).
const dbPath = pgliteDbPath(ctx.gbrainHome);
let preVersion: number | null = null;
if (scenario.kind === 'upgrade') {
const { readPgliteSchemaVersion } = await import('../core/claw-test/seed-pglite.ts');
preVersion = await readPgliteSchemaVersion(dbPath);
}
// ---- PATH shim: the BRIEF says `gbrain …`; make bare `gbrain` resolve to
// THIS harness's binary (operator PATH may have none, or a stale global). ----
const shimDir = join(ctx.runRoot, '.harness-bin');
mkdirSync(shimDir, { recursive: true });
const shimPath = join(shimDir, 'gbrain');
// Single-quoted: validateBinPathEnv rejects quote/metacharacter values, so
// the interpolation cannot break out of the quoting.
writeFileSync(shimPath, `#!/bin/sh\nexec '${gbrainBin}' "$@"\n`, 'utf-8');
chmodSync(shimPath, 0o755);
const sink = createTranscriptSink(ctx.transcriptPath);
const env: Record<string, string> = {
GBRAIN_HOME: ctx.gbrainHome,
GBRAIN_FRICTION_RUN_ID: ctx.runId,
PATH: `${shimDir}:${process.env.PATH ?? ''}`,
};
const brief = readBrief(scenario);
@@ -322,7 +494,7 @@ async function runLive(
cwd: ctx.runRoot,
brief,
env,
timeoutMs: SUBPROCESS_TIMEOUT_MS,
timeoutMs: LIVE_AGENT_TIMEOUT_MS,
transcriptSink: sink,
});
} finally {
@@ -340,9 +512,270 @@ async function runLive(
});
return result.exitCode;
}
// ---- Success oracle: exit code alone passes an agent that did nothing. ----
return verifyLiveOutcome(opts, scenario, ctx, childEnv, gbrainBin, preVersion);
}
/** Stage the workspace per scenario.kind before the agent turn. Returns 0 or a failing exit code. */
async function stageLiveScenario(
opts: HarnessOpts,
scenario: ScenarioConfig,
ctx: { runId: string; runRoot: string; gbrainHome: string },
childEnv: Record<string, string>,
gbrainBin: string,
): Promise<number> {
const failStage = (message: string, hint?: string): number => {
logFriction({
runId: ctx.runId,
phase: 'stage',
message,
severity: 'blocker',
hint,
source: 'harness',
agent: opts.agent,
});
console.error(`[stage] ${message}`);
return 1;
};
if (scenario.kind === 'upgrade') {
// Seed ONLY — running init here would walk the migration chain forward
// and do the very upgrade the agent turn is supposed to perform (any
// gbrain connect auto-migrates). Same seed-first order as scripted mode.
if (scenario.seedRelative) {
const seedSql = join(scenario.dir, scenario.seedRelative, 'dump.sql');
if (existsSync(seedSql)) {
const dbPath = pgliteDbPath(ctx.gbrainHome);
mkdirSync(join(ctx.gbrainHome, '.gbrain'), { recursive: true });
const { seedPgliteFromFile } = await import('../core/claw-test/seed-pglite.ts');
try {
await seedPgliteFromFile({ dbPath, sqlPath: seedSql });
console.log(`[stage] replayed ${seedSql}${dbPath}`);
} catch (e) {
return failStage(`seed replay failed: ${e instanceof Error ? e.message : String(e)}`);
}
} else {
return failStage(`upgrade scenario has no seed dump at ${seedSql}`, 'upgrade runs need a real dump.sql to measure the migration (see the TODOS entry for the v0.18 seed dump)');
}
} else {
return failStage('upgrade scenario declares no seed dir');
}
return 0;
}
// fresh-install: copy the scenario's brain pages + an AGENTS.md stub, then
// init the brain (the BRIEF says the user "just ran gbrain init").
if (scenario.brainRelative) {
const src = join(scenario.dir, scenario.brainRelative);
if (!existsSync(src)) {
// Fail loudly, matching the upgrade branch's missing-seed blocker: a
// silent skip here would fail the query oracle later with the
// misleading hint "the agent likely skipped the import step" when the
// real cause is a broken fixture.
return failStage(`fresh-install scenario declares brain dir ${scenario.brainRelative} but it does not exist at ${src}`);
}
cpSync(src, join(ctx.runRoot, 'brain'), { recursive: true });
}
const agentsMd = join(ctx.runRoot, 'AGENTS.md');
if (!existsSync(agentsMd)) {
// Deliberately references NO skill files: staging creates none, and a row
// pointing at a missing SKILL.md flips doctor's resolver_health to fail
// (caught in rehearsal). Post-v0.33 scaffolded skills route via their own
// frontmatter triggers, so a prose stub satisfies the BRIEF's
// "workspace already has an AGENTS.md routing file" precondition.
writeFileSync(
agentsMd,
'# Workspace routing\n\nSkills scaffolded under `skills/` route via their frontmatter `triggers:`.\n',
'utf-8',
);
}
const init = await invokeGbrain(gbrainBin, ['init', '--pglite', '--no-embedding'], ctx.runRoot, childEnv);
if (init.exitCode !== 0) {
return failStage(`gbrain init failed during staging (exit ${init.exitCode})`, init.stderrTail.trim().slice(0, 500));
}
console.log('[stage] fresh-install workspace staged (brain pages + AGENTS.md + init)');
return 0;
}
/** Post-turn verification. Logs friction (phase 'verify') and returns 1 on any failure. */
async function verifyLiveOutcome(
opts: HarnessOpts,
scenario: ScenarioConfig,
ctx: { runId: string; runRoot: string; gbrainHome: string },
childEnv: Record<string, string>,
gbrainBin: string,
preVersion: number | null,
): Promise<number> {
const failures: { message: string; hint?: string }[] = [];
if (scenario.kind === 'upgrade') {
// The upgrade oracle is the schema version reaching LATEST during the
// agent turn, read via the non-mutating direct-PGLite probe (doctor/any
// CLI connect would apply the migrations itself and mask a do-nothing
// agent). MUST run before any declared query oracle below — the query's
// own CLI connect migrates, which would corrupt a later version read.
const { readPgliteSchemaVersion } = await import('../core/claw-test/seed-pglite.ts');
const { LATEST_VERSION } = await import('../core/migrate.ts');
const dbPath = pgliteDbPath(ctx.gbrainHome);
const postVersion = await readPgliteSchemaVersion(dbPath);
if (preVersion === null || postVersion === null) {
failures.push({ message: `upgrade oracle: schema version unreadable (pre=${preVersion} post=${postVersion})` });
} else if (postVersion <= preVersion) {
failures.push({
message: `upgrade oracle: schema version did not advance during the agent turn (pre=${preVersion} post=${postVersion})`,
hint: 'the agent never ran a gbrain command that walks the migration chain',
});
} else if (postVersion < LATEST_VERSION) {
// Advancing one step is not an upgrade: any gbrain connect migrates to
// latest, so a partial version means the agent's run died mid-chain.
failures.push({
message: `upgrade oracle: schema version advanced but stopped short of latest (pre=${preVersion} post=${postVersion} latest=${LATEST_VERSION})`,
hint: 'a gbrain command started the migration chain but did not complete it',
});
}
} else {
// doctor: fresh --no-embedding brains report status "warnings" (observed
// — embedding setup is deferred by design), so healthy AND warnings both
// pass. Anything else — including output the harness cannot parse — is a
// failure: an oracle that shrugs at unparsable output fails open.
const doc = await invokeGbrain(gbrainBin, ['doctor', '--json'], ctx.runRoot, childEnv, { captureFullStdout: true });
if (doc.exitCode !== 0) {
failures.push({ message: `verify: doctor exited ${doc.exitCode}`, hint: doc.stderrTail.trim().slice(0, 300) });
} else {
const report = parseLastJson(doc.stdoutFull ?? doc.stdoutTail);
const status = report && typeof report === 'object' ? (report as Record<string, unknown>).status : undefined;
if (report === null || typeof status !== 'string') {
failures.push({ message: 'verify: doctor exited 0 but its JSON output was unparsable' });
} else if (status !== 'healthy' && status !== 'warnings') {
failures.push({ message: `verify: doctor reports status ${JSON.stringify(status)}` });
}
}
}
// A declared oracle is enforced for EVERY kind (loadScenario validates it
// for every kind — accepting config it never enforces would be a silent
// no-op; an upgrade agent that migrates the schema but loses the seeded
// data must still fail a declared query oracle).
const oracle = scenario.oracle;
if (oracle?.query) {
const q = await invokeGbrain(gbrainBin, ['query', oracle.query, '--json'], ctx.runRoot, childEnv, { captureFullStdout: true });
const parsed = q.exitCode === 0 ? parseLastJson(q.stdoutFull ?? q.stdoutTail) : null;
const min = oracle.minResults ?? 1;
// query --json emits a bare array; anything else on a zero exit means the
// command's contract broke — fail even when min_results is 0, because
// "0 results required" never licenses unparsable output.
if (q.exitCode === 0 && !Array.isArray(parsed)) {
failures.push({
message: `verify: query ${JSON.stringify(oracle.query)} exited 0 but its JSON output was unparsable`,
});
} else {
const count = Array.isArray(parsed) ? parsed.length : 0;
if (q.exitCode !== 0 || count < min) {
failures.push({
message: `verify: query ${JSON.stringify(oracle.query)} returned ${count} result(s), expected >= ${min} (exit ${q.exitCode})`,
hint: 'the agent likely skipped the import step from the brief',
});
}
}
}
for (const rel of oracle?.filesExist ?? []) {
if (!existsSync(join(ctx.runRoot, rel))) {
failures.push({
message: `verify: expected file missing after run: ${rel}`,
hint: 'the agent likely skipped a brief step that produces this file',
});
}
}
for (const f of failures) {
logFriction({
runId: ctx.runId,
phase: 'verify',
message: f.message,
severity: 'error',
hint: f.hint,
source: 'harness',
agent: opts.agent,
});
console.error(`[verify] ${f.message}`);
}
return failures.length ? 1 : 0;
}
/**
* Parse the trailing JSON document from CLI stdout (defensive: banners or
* notices may precede the payload).
*/
function parseLastJson(stdout: string): unknown {
const text = stdout.trim();
try {
return JSON.parse(text);
} catch { /* fall through */ }
const starts = ['{', '['];
for (let i = 0; i < text.length; i++) {
if (starts.includes(text[i])) {
try {
return JSON.parse(text.slice(i));
} catch { /* keep scanning */ }
}
}
return null;
}
/** Refuse to import child friction files larger than this the file lives in
* a workspace the AGENT writes to, so its size is untrusted. */
const CHILD_FRICTION_MAX_BYTES = 4 * 1024 * 1024;
/**
* E0: merge the child-side friction file (written under the run's hermetic
* GBRAIN_HOME) into the parent process's friction dir so it survives tempdir
* cleanup. Best-effort a merge failure never fails the run.
*
* The child file is UNTRUSTED input (in live mode the agent can write
* arbitrary bytes at that path), and the destination is the operator's
* permanent friction log: require a regular file (no symlink an agent-
* dropped link could import any readable file on the box), cap the size, and
* append only lines that parse as JSON objects so the log stays valid JSONL.
* Exported for tests.
*/
export function mergeChildFriction(runRoot: string, runId: string): void {
try {
const childFile = join(runRoot, '.gbrain', 'friction', `${runId}.jsonl`);
const st = lstatSync(childFile, { throwIfNoEntry: false });
if (!st) return;
if (!st.isFile()) {
console.error(`[friction] skipping child friction merge: ${childFile} is not a regular file`);
return;
}
if (st.size > CHILD_FRICTION_MAX_BYTES) {
console.error(`[friction] skipping child friction merge: ${childFile} is ${st.size} bytes (cap ${CHILD_FRICTION_MAX_BYTES})`);
return;
}
const parentFile = frictionFile(runId);
if (resolve(childFile) === resolve(parentFile)) return;
const raw = readFileSync(childFile, 'utf-8');
if (!raw.trim()) return;
const kept: string[] = [];
let skipped = 0;
for (const line of raw.split('\n')) {
if (!line.trim()) continue;
try {
const parsed = JSON.parse(line);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) kept.push(line);
else skipped++;
} catch {
skipped++;
}
}
if (skipped) console.error(`[friction] child friction merge skipped ${skipped} non-JSONL line(s)`);
if (!kept.length) return;
const dir = frictionDir();
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
appendFileSync(parentFile, kept.join('\n') + '\n', 'utf-8');
} catch { /* best effort */ }
}
// ---------------------------------------------------------------------------
// Subprocess helpers
// ---------------------------------------------------------------------------
@@ -352,17 +785,60 @@ function invokeGbrain(
argv: string[],
cwd: string,
env: Record<string, string>,
invokeOpts?: { captureFullStdout?: boolean },
): Promise<PhaseOutcome> {
return new Promise((resolve) => {
return new Promise((resolvePromise) => {
const start = Date.now();
const child = spawn(bin, argv, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'], shell: false });
const stdout: Buffer[] = [];
const stderr: Buffer[] = [];
child.stdout?.on('data', (b: Buffer) => stdout.push(b));
child.stderr?.on('data', (b: Buffer) => stderr.push(b));
// A hung child (e.g. a leaked PGLite lock holder from the agent turn)
// must not wedge the harness/CI job forever: SIGTERM at the phase cap,
// SIGKILL if it lingers.
let timedOut = false;
let killTimer: ReturnType<typeof setTimeout> | null = null;
const wallClockTimer = setTimeout(() => {
timedOut = true;
try { child.kill('SIGTERM'); } catch { /* already gone */ }
killTimer = setTimeout(() => {
try { child.kill('SIGKILL'); } catch { /* already gone */ }
}, 10_000);
}, SUBPROCESS_TIMEOUT_MS);
const clearTimers = () => {
clearTimeout(wallClockTimer);
if (killTimer) clearTimeout(killTimer);
};
let settled = false;
const settle = (code: number | null) => {
if (settled) return;
settled = true;
clearTimers();
let stderrText = Buffer.concat(stderr).toString('utf-8');
if (timedOut) stderrText += `\nharness: killed after ${SUBPROCESS_TIMEOUT_MS}ms phase timeout`;
const stdoutText = Buffer.concat(stdout).toString('utf-8');
resolvePromise({
phase: '',
exitCode: typeof code === 'number' ? code : (timedOut ? 124 : 1),
durationMs: Date.now() - start,
stderrEvents: parseProgressEvents(stderrText).length,
stdoutTail: tailOf(stdoutText),
stderrTail: stderrText,
// The 4KB tail is fine for logging but NOT for parsing JSON payloads
// (doctor --json exceeds it and would lose its opening brace).
...(invokeOpts?.captureFullStdout ? { stdoutFull: stdoutText } : {}),
});
};
child.on('error', (err) => {
if (settled) return;
settled = true;
clearTimers();
const stderrJoined = Buffer.concat(stderr).toString('utf-8') + '\nspawn error: ' + err.message;
resolve({
resolvePromise({
phase: '',
exitCode: 127,
durationMs: Date.now() - start,
@@ -371,16 +847,13 @@ function invokeGbrain(
stderrTail: tailOf(stderrJoined),
});
});
child.on('close', (code) => {
const stderrText = Buffer.concat(stderr).toString('utf-8');
resolve({
phase: '',
exitCode: typeof code === 'number' ? code : 1,
durationMs: Date.now() - start,
stderrEvents: parseProgressEvents(stderrText).length,
stdoutTail: tailOf(Buffer.concat(stdout).toString('utf-8')),
stderrTail: stderrText,
});
// 'close' (pipes drained) is the clean path; 'exit' + grace covers a
// grandchild that inherits the pipes and outlives the kill — without it a
// timed-out phase whose child leaked a subprocess would wedge forever.
child.on('close', (code) => settle(code));
child.on('exit', (code) => {
const t = setTimeout(() => settle(code), 2_000);
t.unref?.();
});
});
}
@@ -402,7 +875,7 @@ function parseArgs(args: string[]): HarnessOpts {
keepTempdir: false,
listAgents: false,
help: args.includes('--help') || args.includes('-h'),
gbrainBin: process.env.GBRAIN_BIN_OVERRIDE || process.execPath,
gbrainBin: resolveGbrainBin(),
};
for (let i = 0; i < args.length; i++) {
const a = args[i];
@@ -415,6 +888,60 @@ function parseArgs(args: string[]): HarnessOpts {
return out;
}
/**
* Resolve the gbrain binary child invocations use. GBRAIN_BIN_OVERRIDE goes
* through the same absolute/no-dotdot/no-metacharacter validation as the
* *_BIN runner overrides the value is interpolated into a generated
* PATH-shim script in live mode, so a relative value would re-resolve through
* the shimmed PATH and self-exec forever, and quoting-hostile characters
* would become code. Invalid overrides are rejected loudly (stderr) and the
* harness falls back to the current executable.
*
* The fallback is NOT bare process.execPath: under `bun run src/cli.ts` (the
* canonical source install) or a bun-global launcher, execPath is the Bun
* RUNTIME, and children would run `bun init` / `bun import` instead of gbrain
* scaffolding a Bun project in the hermetic workspace and failing the rest
* of the run. When execPath looks like bun, synthesize a launcher shim that
* re-enters this checkout's cli.ts; only a compiled gbrain binary returns
* execPath directly.
*/
let cachedGbrainBin: string | null = null;
function resolveGbrainBin(): string {
if (cachedGbrainBin) return cachedGbrainBin;
cachedGbrainBin = resolveGbrainBinUncached();
return cachedGbrainBin;
}
function resolveGbrainBinUncached(): string {
const override = process.env.GBRAIN_BIN_OVERRIDE?.trim();
if (override) {
const invalid = validateBinPathEnv('GBRAIN_BIN_OVERRIDE', override);
if (!invalid) return override;
console.error(`ignoring ${invalid}; falling back to the current executable`);
}
const exe = process.execPath;
if (/^bun(-profile)?(\.exe)?$/i.test(basename(exe))) {
// src/commands/claw-test.ts → ../cli.ts. Under a compiled binary this
// branch never fires (execPath is the gbrain binary itself); under bun
// (dev checkout or bun-global install) import.meta resolves to the real
// source file next to cli.ts.
const cliTs = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'cli.ts');
if (existsSync(cliTs) && !/['\n\r]/.test(exe) && !/['\n\r]/.test(cliTs)) {
const dir = mkdtempSync(join(tmpdir(), 'gbrain-launcher-'));
const launcher = join(dir, 'gbrain');
writeFileSync(launcher, `#!/bin/sh\nexec '${exe}' '${cliTs}' "$@"\n`, 'utf-8');
chmodSync(launcher, 0o755);
return launcher;
}
console.error(
'claw-test: running under the bun runtime but the gbrain CLI entrypoint could not be located — ' +
'child gbrain invocations would run bun itself. Set GBRAIN_BIN_OVERRIDE to a gbrain binary.',
);
}
return exe;
}
function newRunId(agent: string): string {
const now = new Date();
const ts = now.toISOString().replace(/[-:]/g, '').replace(/\..*/, '').replace('T', '-');
@@ -422,23 +949,30 @@ function newRunId(agent: string): string {
return `claw-test-${ts}-${agent}-${suf}`;
}
function cmdListAgents(): number {
async function cmdListAgents(): Promise<number> {
const names = listRegisteredAgents();
if (!names.length) {
console.log('no agents registered');
return 0;
}
for (const name of names) {
// Detect concurrently but AWAIT all of them, then print in
// listRegisteredAgents() order (the accessor sorts alphabetically).
// The prior fire-and-forget .then() version returned before any detection
// resolved, so output could vanish in CLI teardown.
const lines = await Promise.all(names.map(async (name) => {
try {
const runner = resolveAgentRunner(name);
runner.detect().then((d) => {
const status = d.available ? `available at ${d.binPath}` : `unavailable: ${d.reason}`;
console.log(`${name}: ${status}`);
}).catch(() => { /* best effort */ });
try {
const d = await runner.detect();
return `${name}: ${d.available ? `available at ${d.binPath}` : `unavailable: ${d.reason}`}`;
} catch {
return `${name}: (detect error)`;
}
} catch {
console.log(`${name}: (factory error)`);
return `${name}: (factory error)`;
}
}
}));
for (const line of lines) console.log(line);
return 0;
}
@@ -456,8 +990,13 @@ Defaults:
Scripted mode runs canonical commands without an LLM (CI gate).
Live mode spawns a real agent and lets it drive (~510 min, costs tokens).
Live mode runs YOUR configured agent (it may read/write your real agent home,
e.g. ~/.openclaw or ~/.hermes) against a hermetic brain. The door e2e suite is
the fully hermetic lane.
Examples:
gbrain claw-test --scenario fresh-install
gbrain claw-test --scenario upgrade-from-v0.18 --keep-tempdir
gbrain claw-test --live --agent openclaw`);
gbrain claw-test --live --agent openclaw
gbrain claw-test --live --agent hermes`);
}
+55 -1
View File
@@ -91,6 +91,21 @@ export async function runConfig(engine: BrainEngine, args: string[]) {
console.error('Usage: gbrain config unset <key> | --pattern <prefix>');
process.exit(1);
}
if (key === 'push.allow_unverified_remote' || key === 'hooks.stop_push_debounce_min') {
const { loadConfigFileOnly, saveConfig } = await import('../core/config.ts');
const cfg = loadConfigFileOnly();
const [top, leaf] = key.split('.') as ['push' | 'hooks', string];
const branch = cfg?.[top] as Record<string, unknown> | undefined;
if (cfg && branch && leaf in branch) {
delete branch[leaf];
saveConfig(cfg);
console.log(`Unset ${key} (file plane)`);
} else {
console.error(`Config key not found: ${key}`);
process.exit(1);
}
return;
}
const n = await engine.unsetConfig(key);
if (n > 0) {
console.log(`Unset ${key}`);
@@ -111,7 +126,14 @@ export async function runConfig(engine: BrainEngine, args: string[]) {
// overlays env onto the file) — and report which plane answered on
// stderr, keeping stdout a bare value for scripts.
const filePlane = loadConfig() as Record<string, unknown> | null;
const fileVal = filePlane?.[key];
// Dotted keys (push.allow_unverified_remote, hooks.stop_push_debounce_min)
// are stored NESTED by `set`; resolve the path so `get`/`unset` see them.
const resolveDotted = (obj: Record<string, unknown> | null, k: string): unknown => {
if (!obj) return undefined;
if (k in obj) return obj[k];
return k.split('.').reduce<unknown>((acc, seg) => (acc && typeof acc === 'object' ? (acc as Record<string, unknown>)[seg] : undefined), obj);
};
const fileVal = resolveDotted(filePlane, key);
const dbVal = await engine.getConfig(key);
const val = fileVal !== undefined && fileVal !== null ? fileVal : dbVal;
if (val !== null && val !== undefined) {
@@ -129,6 +151,38 @@ export async function runConfig(engine: BrainEngine, args: string[]) {
process.exit(1);
}
} else if (action === 'set' && key && value) {
// Bootstrap hook-lane keys are FILE-plane canonical: they are read by
// engine-free processes (the harness hook children and the detached
// `sources push` child) via loadConfigFileOnly, which never sees the DB
// plane — and the DB plane is unreadable anyway while a `gbrain serve`
// holds the single-writer lock. Route them to ~/.gbrain/config.json.
if (key === 'push.allow_unverified_remote' || key === 'hooks.stop_push_debounce_min') {
const { loadConfigFileOnly, saveConfig, isConfigTruthy } = await import('../core/config.ts');
const cfg = (loadConfigFileOnly() ?? { engine: 'pglite' }) as Parameters<typeof saveConfig>[0];
if (key === 'push.allow_unverified_remote') {
const on = isConfigTruthy(value);
cfg.push = { ...(cfg.push ?? {}), allow_unverified_remote: on };
saveConfig(cfg);
console.log(`Set ${key} = ${on} (file plane: ~/.gbrain/config.json)`);
if (on) {
console.log(
'WARNING: workspace pushes now SKIP repo-visibility verification. ' +
'This trusts the remote on your word — unset it once verification works: ' +
'gbrain config set push.allow_unverified_remote false',
);
}
} else {
const n = Number.parseInt(value, 10);
if (!Number.isFinite(n) || n < 0) {
console.error(`[config] ${key} must be an integer >= 0 (minutes; 0 = push every turn)`);
process.exit(1);
}
cfg.hooks = { ...(cfg.hooks ?? {}), stop_push_debounce_min: n };
saveConfig(cfg);
console.log(`Set ${key} = ${n} (file plane: ~/.gbrain/config.json)`);
}
return;
}
// v0.37.11.0 fix wave (Lane C.2 + CDX2-13): refuse writes to schema-sizing
// fields unconditionally. These fields size the `content_chunks.embedding`
// column at init time and are file-plane canonical. `gbrain config set
+34 -139
View File
@@ -36,14 +36,44 @@ import { execFileSync } from 'child_process';
import type { ConnectProbeResult } from '../core/connect-probe.ts';
import { probeBrainIdentity, DEFAULT_PROBE_TIMEOUT_MS } from '../core/connect-probe.ts';
import { promptLine } from '../core/cli-util.ts';
import {
NAME_RE,
REDACTED,
buildClaudeMcpAddArgv,
buildCodexMcpAddArgv,
cmdString,
isValidName,
issuerFromMcpUrl,
normalizeMcpUrl,
redactToken,
shellQuote,
validateToken,
} from '../core/mcp-registration.ts';
// The pure registration helpers moved to src/core/mcp-registration.ts for
// #4043 (the bootstrap harness lane consumes them; core must not import from
// commands). Re-exported so this module's public surface — and every test
// that imports from it — is unchanged.
export {
REDACTED,
buildClaudeMcpAddArgv,
buildCodexMcpAddArgv,
cmdString,
isLinkLocalOrMetadata,
issuerFromMcpUrl,
isValidName,
normalizeMcpUrl,
redactToken,
validateToken,
type TokenValidation,
type UrlResult,
} from '../core/mcp-registration.ts';
export const ENV_VAR = 'GBRAIN_REMOTE_TOKEN';
export const PLACEHOLDER_TOKEN = '<paste-your-token>';
export const PLACEHOLDER_SECRET = '<paste-your-client-secret>';
export const REDACTED = '***';
export const DEFAULT_NAME = 'gbrain';
export const DEFAULT_SCOPES = 'read write';
const NAME_RE = /^[a-z0-9][a-z0-9_-]*$/;
// Single source of truth shared with the probe (was a duplicated 15_000 literal).
const DEFAULT_TIMEOUT_MS = DEFAULT_PROBE_TIMEOUT_MS;
@@ -134,108 +164,10 @@ Examples:
`;
// ---------------------------------------------------------------------------
// Pure helpers (unit-tested in test/connect.test.ts)
// Pure helpers (unit-tested in test/connect.test.ts; registration helpers
// live in src/core/mcp-registration.ts and are re-exported above)
// ---------------------------------------------------------------------------
export type UrlResult =
| { ok: true; url: string; warning?: string }
| { ok: false; error: string };
/**
* Block link-local / cloud-metadata addresses the one class of host that is
* never a legitimate brain endpoint but IS a token-exfil target (e.g. the AWS/
* GCP metadata service at 169.254.169.254). Deliberately does NOT block
* localhost or RFC1918/LAN ranges: self-hosted brains on a private network are
* a documented, supported topology (`gbrain serve --http --bind`).
*/
export function isLinkLocalOrMetadata(hostname: string): boolean {
const h = hostname.toLowerCase().replace(/^\[|\]$/g, '');
if (/^169\.254\.\d{1,3}\.\d{1,3}$/.test(h)) return true; // IPv4 link-local incl. cloud metadata
if (h.startsWith('fe80:')) return true; // IPv6 link-local
if (h === 'fd00:ec2::254') return true; // AWS IMDSv2 over IPv6
// IPv4-mapped IPv6 (e.g. ::ffff:169.254.169.254 dotted, or ::ffff:a9fe:xxxx
// hex where a9fe == 169.254) must not slip past the dotted-IPv4 check.
const mapped = h.match(/^::ffff:(.+)$/);
if (mapped) {
if (/^169\.254\.\d{1,3}\.\d{1,3}$/.test(mapped[1])) return true;
if (mapped[1].startsWith('a9fe:')) return true;
}
return false;
}
/**
* Normalize an MCP URL to a canonical `<scheme>//<host><path>` ending in /mcp.
* Explicit spec (not best-effort) see plan D-codex findings.
*/
export function normalizeMcpUrl(input: string): UrlResult {
const raw = (input ?? '').trim();
if (!raw) {
return { ok: false, error: 'Missing MCP URL. Usage: gbrain connect <https://host/mcp> --token <bearer>' };
}
// Require an explicit scheme. A bare `host:3131` parses as scheme `host:`
// under WHATWG URL, so reject anything without `://`.
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(raw)) {
const guess = raw.replace(/^\/+/, '');
return { ok: false, error: `Add an explicit scheme, e.g. https://${guess} (a bare host:port is ambiguous).` };
}
let u: URL;
try {
u = new URL(raw);
} catch {
return { ok: false, error: `Invalid URL: ${raw}` };
}
const scheme = u.protocol.toLowerCase();
if (scheme !== 'http:' && scheme !== 'https:') {
return { ok: false, error: `Only http(s) URLs are supported (got ${u.protocol}).` };
}
if (u.username || u.password) {
return { ok: false, error: 'Remove credentials from the URL (user:pass@host is not supported); pass the token via --token.' };
}
if (u.search) {
return { ok: false, error: 'Remove the query string from the MCP URL.' };
}
if (isLinkLocalOrMetadata(u.hostname)) {
return { ok: false, error: `Refusing to target a link-local / cloud-metadata address (${u.hostname}). Point the MCP URL at the brain host's real address.` };
}
const host = u.host; // host:port; hostname already lowercased by URL
const path = u.pathname;
const trimmed = path.replace(/\/+$/, '');
const lower = trimmed.toLowerCase();
let finalPath: string;
if (path === '' || path === '/') {
finalPath = '/mcp';
} else if (lower === '/mcp') {
finalPath = '/mcp';
} else {
return {
ok: false,
error: `Unexpected path '${path}'. Pass the full /mcp URL, e.g. ${scheme}//${host}${trimmed}/mcp`,
};
}
const url = `${scheme}//${host}${finalPath}`;
const hn = u.hostname.toLowerCase();
const isLocal = hn === 'localhost' || hn === '127.0.0.1' || hn === '::1' || hn === '[::1]';
if (scheme === 'http:' && !isLocal) {
return { ok: true, url, warning: 'Warning: http:// sends your bearer token unencrypted. Use https:// unless this is localhost.' };
}
return { ok: true, url };
}
/** The OAuth issuer is the server base — the /mcp endpoint's URL minus /mcp. */
export function issuerFromMcpUrl(url: string): string {
return url.replace(/\/mcp$/, '');
}
export type TokenValidation = { ok: true } | { ok: false; error: string };
/** Reject empty/whitespace/control-char tokens (a newline is a header-injection vector). */
export function validateToken(token: string): TokenValidation {
if (!token || !token.trim()) return { ok: false, error: 'Token is empty.' };
if (/\s/.test(token)) return { ok: false, error: 'Token contains whitespace (space/tab/newline) — refusing (header-injection risk).' };
if (/[\x00-\x1f\x7f]/.test(token)) return { ok: false, error: 'Token contains control characters — refusing (header-injection risk).' };
return { ok: true };
}
export type TokenResolution =
| { kind: 'literal'; token: string }
| { kind: 'placeholder' }
@@ -255,43 +187,6 @@ export function resolveToken(opts: { tokenFlag?: string | null; env?: string | n
};
}
export function isValidName(name: string): boolean {
return NAME_RE.test(name);
}
export function buildClaudeMcpAddArgv(p: { name: string; url: string; headerToken: string }): string[] {
return ['mcp', 'add', p.name, '-t', 'http', p.url, '-H', `Authorization: Bearer ${p.headerToken}`];
}
/** Codex reads the bearer from an env var at runtime — the token is NOT in argv. */
export function buildCodexMcpAddArgv(p: { name: string; url: string; envVar: string }): string[] {
return ['mcp', 'add', p.name, '--url', p.url, '--bearer-token-env-var', p.envVar];
}
/**
* POSIX single-quote any arg that isn't already shell-safe, so `$()`, backticks,
* etc. in a token are inert literals when the block is pasted into a shell
* (double-quoting would still allow command substitution).
*/
function shellQuote(arg: string): string {
if (/^[A-Za-z0-9_.:/@-]+$/.test(arg)) return arg;
return `'${arg.replace(/'/g, "'\\''")}'`;
}
/** Render `<binary> <argv...>` as a copy-pasteable, shell-safe command string. */
export function cmdString(binary: string, argv: string[]): string {
return `${binary} ${argv.map(shellQuote).join(' ')}`;
}
export function redactToken(s: string, token: string | null): string {
// Exact-substring scrub of the known token, plus a defense-in-depth pass over
// any `Bearer <value>` shape the SDK/CLI might echo in a transformed form the
// exact match would miss. Both run on the --install error paths only.
let out = token ? s.split(token).join(REDACTED) : s;
out = out.replace(/Bearer\s+\S+/gi, `Bearer ${REDACTED}`);
return out;
}
export interface OAuthCreds {
issuer: string;
clientId: string;
+609 -54
View File
@@ -72,7 +72,7 @@ import { escapeLikePattern, buildVisibilityClause } from '../core/search/sql-ran
import { unverifiedExtractionFragment } from '../core/extraction-review.ts';
import { hnswIndexExpected, hnswMaxDimsForType } from '../core/vector-index.ts';
// Agent-bootstrap doctor group (plan B2/B4/ENG-4 + one-live-serve note).
import { readReceipt } from '../core/bootstrap/format.ts';
import { readHarnessReceiptState, readReceipt } from '../core/bootstrap/format.ts';
import { probeLivePgliteHolder, resolveBrainDataDir } from '../core/bootstrap/uninstall.ts';
import { readRunbookStamp, hooksInstalled, listVerifyRuns } from '../core/bootstrap/status.ts';
import { resolveGbrainHome } from '../core/gbrain-home.ts';
@@ -782,6 +782,124 @@ export async function checkSourceConfigShape(engine: BrainEngine): Promise<Check
}
}
/**
* #2674 pglite_scratch_probe: distinguish a damaged PGLite store from a
* broken WASM runtime.
*
* PGLite reports only `Aborted()` to JS (the PANIC goes to its own stderr),
* so when init fails, the error string cannot say WHICH of the two it is.
* The probe initializes a throwaway store in a temp dir, round-trips a row,
* and reads the outcome:
*
* - scratch works, real init failed the runtime is fine; the failure is
* specific to YOUR store. The store-damage verdict is only ASSERTED when
* the caller supplies positive evidence (`storeDamageEvidence`: a
* damage-class disk diagnosis from `inspectPgliteDataDir`, or a
* wasm-abort/corrupt classification of the real init error). engine=null
* alone also covers locks and config refusals blaming the store for
* those was the original false-positive defect; without evidence the
* message hedges and points at the `pglite_data_dir` diagnosis instead.
* - scratch fails too the runtime cannot start on this machine; report
* OS + Bun versions on #223.
*
* COST GATE: a PGLite cold start is 520s on loaded machines, so this never
* runs on a routine `gbrain doctor`. It runs only when (a) the real PGLite
* engine actually failed to open (engine=null, not --fast, configured engine
* is pglite) AND the disk diagnosis didn't already fully explain the failure
* (a live lock / missing dir needs no runtime probe), or (b) the operator
* asks with `--probe-pglite`.
*
* `probeFn` is a test seam so message routing can be pinned without paying
* real cold starts.
*/
export async function checkPgliteScratchProbe(opts: {
realInitFailed: boolean;
/**
* Positive evidence the REAL store is damaged: `inspectPgliteDataDir`
* verdict wal-corruption-likely/unsupported-layout (buildChecks path) or a
* wasm-abort/corrupt classification of the actual connect error (remote
* path). Without it the scratch-ok arm hedges instead of asserting damage.
*/
storeDamageEvidence?: boolean;
realStorePath?: string;
probeFn?: () => Promise<import('../core/pglite-engine.ts').PgliteScratchProbeResult>;
}): Promise<Check> {
const name = 'pglite_scratch_probe';
try {
const probe =
opts.probeFn ??
(async () => {
const { probePgliteScratchStore } = await import('../core/pglite-engine.ts');
return probePgliteScratchStore(opts.realStorePath);
});
const r = await probe();
const secs = (r.duration_ms / 1000).toFixed(1);
if (r.ok) {
if (opts.realInitFailed && opts.storeDamageEvidence) {
return {
name,
status: 'fail',
message:
`A scratch PGLite store initialized, wrote and read back fine on this machine (${secs}s), ` +
`so the runtime is healthy and YOUR STORE is damaged — not the WASM runtime. ` +
`Your markdown is unaffected: the DB holds derived data (chunks, embeddings, links, facts) that a re-sync rebuilds. ` +
`Recover: \`gbrain pglite-repair --dry-run\` to diagnose, \`gbrain pglite-repair --yes\` for in-place WAL repair (data preserved); ` +
`if that can't fix it, restore a backup of the store directory or run \`gbrain reinit-pglite\` (wipes + re-inits + re-syncs; ` +
`defaults embedding flags from your config file).`,
details: { scratch_ok: true, duration_ms: r.duration_ms },
};
}
if (opts.realInitFailed) {
// Runtime proven healthy, but no independent evidence of store DAMAGE
// — engine=null also covers locks, config refusals, and transient
// failures. Hedge rather than convict the store (#2674 review).
return {
name,
status: 'warn',
message:
`A scratch PGLite store initialized, wrote and read back fine on this machine (${secs}s), ` +
`so the WASM runtime is healthy — the failure opening your brain is specific to your store, ` +
`its lock, or its configuration. See the \`pglite_data_dir\` check for the on-disk diagnosis; ` +
`\`gbrain pglite-repair --dry-run\` diagnoses without mutating anything.`,
details: { scratch_ok: true, duration_ms: r.duration_ms },
};
}
return {
name,
status: 'ok',
message: `PGLite runtime healthy: scratch store round-trip in ${secs}s.`,
details: { scratch_ok: true, duration_ms: r.duration_ms },
};
}
const errLine = (r.error ?? 'unknown error').split('\n')[0];
if (opts.realInitFailed) {
return {
name,
status: 'fail',
message:
`A fresh scratch PGLite store ALSO failed to start (${secs}s), so the WASM runtime cannot run ` +
`on this machine — your store is not necessarily damaged. Report your OS and Bun versions on ` +
`https://github.com/garrytan/gbrain/issues/223. Scratch error: ${errLine}`,
details: { scratch_ok: false, duration_ms: r.duration_ms, error: r.error, verdict: r.verdict },
};
}
return {
name,
status: 'warn',
message:
`Your real store opened, but a fresh scratch PGLite store failed to initialize (${secs}s) — ` +
`new stores can't be created on this machine. Report your OS and Bun versions on ` +
`https://github.com/garrytan/gbrain/issues/223. Scratch error: ${errLine}`,
details: { scratch_ok: false, duration_ms: r.duration_ms, error: r.error, verdict: r.verdict },
};
} catch (e) {
// Includes the never-touch-the-real-store guard refusal. The probe not
// running is a diagnostic gap, not a diagnosis — warn, don't fail.
const msg = e instanceof Error ? e.message : String(e);
return { name, status: 'warn', message: `scratch probe could not run: ${msg}` };
}
}
export async function doctorReportRemote(
engine: BrainEngine,
opts: { sourceIds?: string[] } = {},
@@ -804,6 +922,23 @@ export async function doctorReportRemote(
status: 'fail',
message: e instanceof Error ? e.message : String(e),
});
// #2674: on PGLite, a dead connection is exactly the ambiguous case the
// scratch probe exists for — pay its cold start only on this failure path.
// Unlike buildChecks (where the connect error was swallowed upstream), the
// real error IS in hand here: classify it, and only let the probe assert
// store damage on a damage-class verdict (wasm-abort/corrupt) — a lock or
// config refusal classifies 'unknown' and gets the hedged message.
if (engine.kind === 'pglite') {
let realStorePath: string | undefined;
try { realStorePath = loadConfig()?.database_path; } catch { /* no config */ }
let storeDamageEvidence = false;
try {
const { classifyPgliteInitError, stringifyPgliteInitError } = await import('../core/pglite-engine.ts');
const verdict = classifyPgliteInitError(stringifyPgliteInitError(e));
storeDamageEvidence = verdict === 'wasm-abort' || verdict === 'corrupt';
} catch { /* classifier unavailable — stay hedged (fail-closed) */ }
checks.push(await checkPgliteScratchProbe({ realInitFailed: true, storeDamageEvidence, realStorePath }));
}
// Without a connection, every other check is meaningless — short-circuit.
return computeDoctorReport(checks);
}
@@ -1150,8 +1285,7 @@ export function checkSelfUpgradeHealth(): Check {
const { loadConfig } = require('../core/config.ts');
const {
resolveSelfUpgradeMode,
readUpdateCache,
isCacheFresh,
pendingUpgradeVersion,
} = require('../core/self-upgrade.ts');
const { readRecentSelfUpgrades } = require('../core/audit/self-upgrade-audit.ts');
@@ -1166,9 +1300,11 @@ export function checkSelfUpgradeHealth(): Check {
}
const parts: string[] = [`mode=${mode}`];
const entry = readUpdateCache();
if (entry && isCacheFresh(entry, Date.now()) && entry.marker.kind === 'upgrade_available') {
parts.push(`update available: ${entry.marker.current} -> ${entry.marker.latest} (run: gbrain self-upgrade)`);
// Shared stale/foreign-cache guard: only report an upgrade strictly newer
// than the RUNNING binary (pendingUpgradeVersion owns the rule).
const pendingLatest = pendingUpgradeVersion(GBRAIN_BINARY_VERSION, Date.now());
if (pendingLatest) {
parts.push(`update available: ${GBRAIN_BINARY_VERSION} -> ${pendingLatest} (run: gbrain self-upgrade)`);
}
const failedVersions: string[] = cfg?.self_upgrade?.failed_versions ?? [];
if (failedVersions.length > 0) {
@@ -1746,6 +1882,8 @@ export async function checkVoiceGateHealth(engine: BrainEngine): Promise<Check>
* Below that they're noise; reranker fails open anyway.
* 5) Payload-too-large failures: warn at >=1 (indicates a workload
* mismatch that the operator should know about).
* 6) Budget/pricing failures: warn at >=1 with the rerank pricing surface
* and --max-cost escape hatch.
*
* Engine-agnostic (file-based + one config-key read).
*/
@@ -1784,6 +1922,15 @@ export async function checkRerankerHealth(engine: BrainEngine): Promise<Check> {
};
}
const budgetFails = failures.filter((f) => f.reason === 'budget');
if (budgetFails.length > 0) {
return {
name: 'reranker_health',
status: 'warn',
message: `${budgetFails.length} reranker budget/pricing failure(s) in last 7 days. Fix: add rerank pricing to src/core/embedding-pricing.ts or drop --max-cost.`,
};
}
const transientFails = failures.filter(
(f) => f.reason === 'network' || f.reason === 'timeout' || f.reason === 'rate_limit',
);
@@ -1940,13 +2087,48 @@ export async function computeQueueHealthCheck(
[`${oldWaitingHours} hours`],
);
let liveWorkerQueues = new Set<string>();
if (oldWaitingRows.length > 0) {
const workers = opts.readWorkers
? opts.readWorkers()
: (await import('../core/minions/worker-registry.ts')).readWorkers();
liveWorkerQueues = new Set(workers.map((w) => w.queue));
}
// Read the live-worker registry unconditionally (was: only when old
// embed-backfill rows existed) — the structured `details.worker_alive`
// below needs it on every run. Cheap: one directory enumeration.
const workers = opts.readWorkers
? opts.readWorkers()
: (await import('../core/minions/worker-registry.ts')).readWorkers();
const liveWorkerQueues = new Set(workers.map((w) => w.queue));
// Minions-visibility wave: structured details so machine callers stop
// parsing prose. depth = total waiting jobs; oldest_age_seconds = age of
// the oldest waiting job (null when the queue is empty); worker_alive =
// every queue holding waiting work has a live registered worker
// (vacuously true with zero waiting jobs). Messages stay unchanged.
// Perf note (twin of buildQueueDepths in status.ts): WHERE constrains
// only `status` — the second column of the (queue, status, updated_at)
// wedge index — so this GROUP BY full-scans minion_jobs today. Acceptable
// at doctor frequency over pruned waiting sets; a partial
// (queue, created_at) WHERE status='waiting' index is the fix if hot.
const waitingByQueue: Array<{
queue: string;
depth: number | string;
oldest_age_seconds: number | string | null;
}> = await engine.executeRaw(
`SELECT queue,
count(*)::int AS depth,
EXTRACT(EPOCH FROM (now() - min(created_at)))::int AS oldest_age_seconds
FROM minion_jobs
WHERE status = 'waiting'
GROUP BY queue`,
);
const details: Record<string, unknown> = {
depth: waitingByQueue.reduce((n, r) => n + Number(r.depth), 0),
oldest_age_seconds: waitingByQueue.reduce<number | null>(
(max, r) => {
const age = r.oldest_age_seconds === null ? null : Number(r.oldest_age_seconds);
if (age === null) return max;
return max === null ? age : Math.max(max, age);
},
null,
),
worker_alive: waitingByQueue.every((r) => liveWorkerQueues.has(r.queue)),
};
const problems: string[] = [];
if (stalledRows.length > 0) {
@@ -1999,12 +2181,14 @@ export async function computeQueueHealthCheck(
name: 'queue_health',
status: 'ok',
message: `No stalled-forever jobs; no queue over depth ${threshold}; no old embed-backfill jobs without a worker.`,
details,
};
}
return {
name: 'queue_health',
status: 'warn',
message: problems.join(' '),
details,
};
} catch (e) {
return {
@@ -2501,6 +2685,131 @@ export async function checkZeEmbeddingHealth(engine: BrainEngine): Promise<Check
}
}
/**
* provider_sunset doctor check (#3390 follow-up).
*
* Detects a brain whose EFFECTIVE embedding model (gateway-resolved, which is
* how default-config brains land on the shipped default) is on a provider
* with an announced hosted-API shutdown, and prints a paste-ready migration
* command with the brain's ACTUAL `content_chunks.embedding` column width
* filled in not the config value, which can drift. Keeping the current
* width avoids a needless dimension transition + index rebuild when the
* target supports it.
*
* Unlike the one-shot upgrade banner (`ze_sunset_notice_shown`), this fires
* on every `gbrain doctor` run until the brain is off the provider
* warn before the shutdown date; fail after it ONLY when the brain is
* actually exposed (embedded vectors exist in the affected column, so
* retrieval is genuinely down). A zero-vector brain whose config merely
* RESOLVES to the dead default stays warn otherwise every stock fresh
* install (and every doctor-as-CI-gate) starts exiting 1 on the date with
* no code change. Suppress entirely (accepted-risk installs) via
* `gbrain config set doctor.suppress_provider_sunset true`.
* No network call; one catalog query for the column width.
*
* `now` is injectable so tests can pin BOTH sides of the date without
* waiting for the calendar (the date itself is a compile-time constant).
*/
export async function checkProviderSunset(engine: BrainEngine, now: number = Date.now()): Promise<Check> {
const name = 'provider_sunset';
try {
const suppressed = await engine.getConfig('doctor.suppress_provider_sunset').catch(() => null);
if (suppressed === 'true' || suppressed === '1') {
return {
name,
status: 'ok',
message: 'Check suppressed via doctor.suppress_provider_sunset (unset it to re-enable).',
};
}
const { DEFAULT_EMBEDDING_MODEL, ZEROENTROPY_SUNSET_DATE } = await import('../core/ai/defaults.ts');
// Effective model: gateway when configured (file/env plane, the runtime
// truth); the shipped default otherwise — an unset-config brain resolves
// to the default at runtime, so it is just as affected.
let model = DEFAULT_EMBEDDING_MODEL;
try {
const { getEmbeddingModel } = await import('../core/ai/gateway.ts');
model = getEmbeddingModel();
} catch {
// Gateway unconfigured — runtime resolves the shipped default.
}
// Effective reranker: resolve through the SAME plane search actually
// reranks with — resolveSearchMode (mode bundle + search.reranker.*
// config overrides; hybrid.ts passes `resolvedMode.reranker_model`).
// The gateway plane is unset by default while balanced/tokenmax rerank
// with the bundle's zeroentropyai model — reading the gateway here
// would false-ok the exact brains this check exists to protect.
let reranker: string | undefined;
try {
const { loadSearchModeConfig, resolveSearchMode } = await import('../core/search/mode.ts');
const knobs = resolveSearchMode(await loadSearchModeConfig(engine));
if (knobs.reranker_enabled) reranker = knobs.reranker_model;
} catch {
// Mode resolution failed — make no reranker-exposure claim.
}
const onSunsetEmbedding = model.startsWith('zeroentropyai:');
const onSunsetReranker = !!reranker?.startsWith('zeroentropyai:');
if (!onSunsetEmbedding && !onSunsetReranker) {
return {
name,
status: 'ok',
message: `No configured provider has an announced shutdown (embedding: ${model}).`,
};
}
const past = now >= Date.parse(`${ZEROENTROPY_SUNSET_DATE}T00:00:00Z`);
const parts: string[] = [];
let hasVectors = false;
if (onSunsetEmbedding) {
let dims: number | null = null;
try {
const { readContentChunksEmbeddingDim } = await import('../core/embedding-dim-check.ts');
dims = (await readContentChunksEmbeddingDim(engine)).dims;
} catch {
// Column probe failed (fresh/odd brain) — omit --dim from the hint.
}
try {
const rows = await engine.executeRaw(
`SELECT 1 AS one FROM content_chunks WHERE embedding IS NOT NULL LIMIT 1`,
);
hasVectors = rows.length > 0;
} catch {
// Probe failed (fresh/odd brain) — no exposure claim, warn-only.
}
const dimFlag = dims ? ` --dim ${dims}` : '';
parts.push(
past
? hasVectors
? `embedding_model="${model}": the hosted API shut down on ${ZEROENTROPY_SUNSET_DATE} — semantic retrieval is offline (queries can no longer be embedded against your existing vectors).`
: `embedding_model="${model}": the hosted API shut down on ${ZEROENTROPY_SUNSET_DATE}. No embedded vectors exist yet, so retrieval is not impacted — but embedding will fail until the config points elsewhere.`
: `embedding_model="${model}": the hosted API shuts down on ${ZEROENTROPY_SUNSET_DATE}. On that date semantic retrieval stops entirely — existing vectors become unqueryable (query embedding uses the same endpoint), not just new content.`,
);
parts.push(
`Two fixes, either works: ` +
`[1] self-host the same model — zembed-1 weights are Apache-2.0; serve them via llama-server or Ollama and point the config at the local endpoint. Keeps every existing vector, no re-embed (docs/guides/embedding-migration.md, "Self-hosting instead of migrating"). ` +
`[2] migrate to another provider (resumable; preview cost first): ` +
`gbrain migrate embeddings --to <provider:model>${dimFlag} --dry-run` +
(dims ? ` — keep --dim ${dims} (this brain's actual index width) to avoid a needless schema rebuild when the target supports it.` : ''),
);
}
if (onSunsetReranker) {
parts.push(
`The reranker (${reranker}) is on the same provider; after the shutdown search falls back to unreranked ordering. ` +
`Fix: gbrain config set search.reranker.enabled false, or point search.reranker.model at another provider.`,
);
}
if (onSunsetEmbedding || onSunsetReranker) {
parts.push('Accepted the risk? Silence this check: gbrain config set doctor.suppress_provider_sunset true');
}
// fail = retrieval is ACTUALLY down (past the date AND embedded vectors
// exist on the dead provider). Reranker-only exposure stays warn — search
// fails open to unreranked ordering (degraded, not down).
const failNow = past && onSunsetEmbedding && hasVectors;
return { name, status: failNow ? 'fail' : 'warn', message: parts.join(' ') };
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
return { name, status: 'warn', message: `Could not check provider sunset status: ${msg}` };
}
}
/**
* v0.36.0.0 (A5): embedding_width_consistency doctor check.
*
@@ -5605,7 +5914,15 @@ export async function buildChecks(
if (lastStarted && engine) {
const queue = typeof lastStarted.queue === 'string' ? lastStarted.queue : 'default';
const effectiveMaxRss = typeof lastStarted.max_rss_mb === 'number' ? lastStarted.max_rss_mb : null;
const localPid = readSupervisorPid(DEFAULT_PID_FILE).pid;
// The 'started' event already records the pid-file path actually in use
// (this.opts.pidFile, which reflects a custom --pid-file). Prefer that
// over re-deriving DEFAULT_PID_FILE locally so a custom --pid-file
// deployment doesn't false-positive a singleton mismatch against itself.
// Falls back to DEFAULT_PID_FILE when the event carries no usable value.
const pidFilePath = typeof lastStarted.pid_file === 'string' && lastStarted.pid_file.length > 0
? lastStarted.pid_file
: DEFAULT_PID_FILE;
const localPid = readSupervisorPid(pidFilePath).pid;
const localHost = hostname();
// Read the DB singleton lock holder for this queue.
@@ -6181,6 +6498,44 @@ export async function buildChecks(
// Best-effort environment check; never block doctor.
}
// 3g. pglite_leftovers (#3856). A pglite -> postgres migration leaves the
// old engine store (`brain.pglite/`) under the gbrain home forever — dead
// weight roughly the size of the live DB that nothing surfaces, silently
// riding along in any backup that archives the home dir. Assessment is a
// pure helper (src/core/pglite-leftovers-check.ts); it warns ONLY for a
// durable postgres engine, and skips while `migrate-manifest.json` exists
// (an in-flight/interrupted migration can make `brain.pglite` the LIVE
// target while the durable engine still reads postgres — #3194) and for
// everything else (fail open).
// The engine is read from config.json DIRECTLY, not loadConfig(): a
// transient DATABASE_URL (#427) can make a live PGLite brain resolve as
// postgres for one process, and deletion advice must never rest on an
// env override (Codex review P1).
// Warn-only by design: WHEN the abandoned store is safe to drop is a
// policy question (#3856), so the remediation is a verified manual delete
// — no CLI command is named that does not exist (#3697).
try {
const { readFileSync } = await import('node:fs');
const durableEngine = (
JSON.parse(readFileSync(join(gbrainPath(), 'config.json'), 'utf8')) as { engine?: unknown }
).engine;
const { assessPgliteLeftovers } = await import('../core/pglite-leftovers-check.ts');
const leftovers = assessPgliteLeftovers(
typeof durableEngine === 'string' ? durableEngine : undefined,
gbrainPath(),
);
if (leftovers.status !== 'skip') {
checks.push({
name: 'pglite_leftovers',
status: leftovers.status,
message: leftovers.message,
});
}
} catch {
// Best-effort filesystem-hygiene check; never block doctor (a missing/
// unparseable config.json lands here and skips, same fail-open posture).
}
// 3b-multi-source. Multi-source drift (v0.31.8 — D8 + D17 + OV12 + OV13).
// Pre-v0.30.3 putPage misrouted multi-source writes to (default, slug).
// For each non-default source with local_path set, walk the FS and surface
@@ -6275,24 +6630,62 @@ export async function buildChecks(
// Filesystem read failure is non-fatal.
}
// 3d. PGLite data-dir diagnosis (WAL-repair wave). Only meaningful when the
// connect already FAILED on a PGLite brain (engine === null): the connect
// error was swallowed by the fs-only fallback, so this check re-derives the
// dir state from disk and names the repair ladder. Skipped under --fast
// (connect wasn't attempted, so "engine === null" proves nothing there).
if (!fastMode && !engine) {
try {
const cfg = loadConfig();
if (cfg?.engine === 'pglite') {
// 3d. PGLite data-dir diagnosis (WAL-repair wave) + scratch-store probe
// (#2674). The data-dir check re-derives the failure state from DISK (the
// connect error was swallowed by the fs-only fallback); the probe adds the
// RUNTIME dimension (a throwaway store that opens fine proves the WASM
// runtime is healthy). Both only fire when the connect already FAILED on a
// PGLite brain (engine === null, not --fast — under --fast connect wasn't
// attempted, so "engine === null" proves nothing there).
//
// Probe cost gate (a PGLite cold start is 520s): auto-runs ONLY when init
// failed AND the disk diagnosis didn't already fully explain it — a live
// lock or a missing dir needs no runtime probe (and 'locked' was exactly
// the reviewed false-positive: blaming the store while `gbrain serve` held
// it). Explicit --probe-pglite always runs it. A routine healthy
// `gbrain doctor` never pays it.
{
const probeRequested = args.includes('--probe-pglite');
let cfgForProbe: ReturnType<typeof loadConfig> = null;
try { cfgForProbe = loadConfig(); } catch { /* no config — nothing to diagnose */ }
const pgliteInitFailed = !engine && !fastMode && cfgForProbe?.engine === 'pglite';
let dirVerdict: import('../core/pglite-repair.ts').PgliteDirDiagnosis['verdict'] | undefined;
if (pgliteInitFailed) {
try {
const { inspectPgliteDataDir } = await import('../core/pglite-repair.ts');
const { resolve } = await import('node:path');
// Absolutize: a RELATIVE database_path would make the sidecar/backup
// lookups resolve against doctor's cwd instead of the engine's.
const pgliteDataDir = resolve(cfg.database_path || gbrainPath('brain.pglite'));
checks.push(computePgliteDataDirCheck(pgliteDataDir, inspectPgliteDataDir(pgliteDataDir)));
const pgliteDataDir = resolve(cfgForProbe!.database_path || gbrainPath('brain.pglite'));
const diagnosis = inspectPgliteDataDir(pgliteDataDir);
dirVerdict = diagnosis.verdict;
checks.push(computePgliteDataDirCheck(pgliteDataDir, diagnosis));
} catch {
// Best-effort: an unreadable config or fs failure must not stop doctor.
}
}
const dirExplainsFailure = dirVerdict === 'locked' || dirVerdict === 'missing';
if (probeRequested || (pgliteInitFailed && !dirExplainsFailure)) {
progress.start('doctor.pglite_probe');
const stopHb = startHeartbeat(progress, 'pglite scratch-store probe (cold start, can take 520s)…');
try {
checks.push(
await checkPgliteScratchProbe({
// A lock/missing dir explains the failure without the store being
// damaged — an explicit --probe-pglite there still reports on the
// runtime, but must not treat the store as the convicted party.
realInitFailed: pgliteInitFailed && !dirExplainsFailure,
storeDamageEvidence:
dirVerdict === 'wal-corruption-likely' || dirVerdict === 'unsupported-layout',
realStorePath: cfgForProbe?.database_path,
}),
);
} finally {
stopHb();
progress.finish();
}
} catch {
// Best-effort: an unreadable config or fs failure must not stop doctor.
}
}
@@ -8298,6 +8691,11 @@ export async function buildChecks(
// v0.36.0.0 (A5): ZE embedding key health + schema/config width consistency.
progress.heartbeat('ze_embedding_health');
checks.push(await checkZeEmbeddingHealth(engine));
// provider_sunset — brain pinned to a provider with an announced
// hosted-API shutdown; paste-ready migration hint with the actual
// column width. Warn before the date, fail after.
progress.heartbeat('provider_sunset');
checks.push(await checkProviderSunset(engine));
progress.heartbeat('embedding_width_consistency');
checks.push(await checkEmbeddingWidthConsistency(engine));
// v0.41.15.0 (T6, codex #19/#20) — facts.embedding column drift
@@ -8371,13 +8769,89 @@ export async function bootstrapDoctorChecks(engine: BrainEngine | null): Promise
return [];
}
const receipt = readReceipt(home);
const pushStatusFile = join(home, 'bootstrap', 'push-status.json');
// One reader for every push-status surface [D8]; per-root files [D13].
const { readPushStatuses, pushStatusFilesExist } = await import('../core/workspace-push.ts');
const pushStatuses = readPushStatuses();
const statusFilesOnDisk = pushStatusFilesExist();
const heartbeatFile = join(home, 'integrations', 'hooks', 'heartbeat.jsonl');
const hasBootstrapState = receipt !== null || existsSync(pushStatusFile) || existsSync(heartbeatFile);
// #4043: a harness-only box (bootstrap harness, no workspace install) is
// bootstrap state too — without this, such a machine gets ZERO checks.
const harnessState = readHarnessReceiptState(home);
const hasBootstrapState =
receipt !== null || statusFilesOnDisk || existsSync(heartbeatFile) || harnessState.state !== 'absent';
if (!hasBootstrapState) return [];
const ws = receipt?.workspace_dir ?? null;
// 0. Harness registration health (#4043): three states so it neither cries
// wolf nor goes silent — skip (not a harness box) / warn (serve unreachable,
// a normal transient; or receipt unreadable) / fail (a target failed, or a
// prior rotation never converged). Token liveness needs the bearer (only
// recoverable from host config) — that's `gbrain bootstrap harness
// --status`'s job; doctor stays offline-cheap.
if (harnessState.state === 'ok') {
const hr = harnessState.receipt;
const failed = hr.targets.filter((t) => t.state === 'failed');
const pending = hr.targets.filter((t) => t.state === 'pending');
if (failed.length > 0 || pending.length > 0) {
checks.push({
name: 'bootstrap_harness_health',
status: 'fail',
message:
`harness wiring incomplete: ${failed.length} failed / ${pending.length} pending target(s)` +
` — re-run \`gbrain bootstrap harness\` to converge (details: gbrain bootstrap harness --status).`,
});
} else if (hr.token.previous_ids && hr.token.previous_ids.length > 0) {
checks.push({
name: 'bootstrap_harness_health',
status: 'fail',
message: `${hr.token.previous_ids.length} previous harness token(s) were never revoked (ids ${hr.token.previous_ids.join(', ')}) — re-run \`gbrain bootstrap harness\`, or run \`gbrain auth revoke\` with the id flag per id.`,
});
} else if (hr.targets.length === 0 && hr.token.minted && hr.token.id !== undefined) {
// Half-removed state: a remove under a live PGLite serve strips every
// host target but defers the revoke — the wiring is gone yet the minted
// token stays ACTIVE. A vacuous all-confirmed must not read green.
// (Flag names spelled without dashes here: the flag-registry generator
// harvests bare flag tokens from comments one import level deep.)
checks.push({
name: 'bootstrap_harness_health',
status: 'fail',
message: `harness removal pending: host wiring removed but the minted token (id ${hr.token.id}) is not yet revoked — stop the serve and re-run \`gbrain bootstrap harness\` with the remove flag, or run \`gbrain auth revoke\` with the id flag.`,
});
} else {
try {
const base = hr.url.replace(/\/mcp$/, '');
const res = await fetch(`${base}/health`, { signal: AbortSignal.timeout(3000) });
const body = res.ok ? ((await res.json()) as { status?: string }) : null;
if (body?.status === 'ok') {
checks.push({
name: 'bootstrap_harness_health',
status: 'ok',
message: `harness wired to ${hr.url} (serve healthy; token check: gbrain bootstrap harness --status)`,
});
} else {
checks.push({
name: 'bootstrap_harness_health',
status: 'warn',
message: `harness wired to ${hr.url} but the serve is not answering /health — start \`gbrain serve\` in http mode (a down serve is a normal transient, sessions just lose brain access until it returns).`,
});
}
} catch {
checks.push({
name: 'bootstrap_harness_health',
status: 'warn',
message: `harness wired to ${hr.url} but the serve is unreachable — start \`gbrain serve\` in http mode.`,
});
}
}
} else if (harnessState.state !== 'absent') {
checks.push({
name: 'bootstrap_harness_health',
status: 'warn',
message: `the harness receipt is unreadable (${harnessState.state}) — see \`gbrain bootstrap harness --status\`.`,
});
}
// 1. Hook heartbeat failure rate [B3 read side]. Hard errors only —
// degraded entries are DESIGNED fallbacks (pull-mode, no serve).
let hooksSeen = false;
@@ -8414,43 +8888,124 @@ export async function bootstrapDoctorChecks(engine: BrainEngine | null): Promise
}
// 2. Push staleness [B4]: fail when the last successful push is >48h old
// AND the workspace tree is dirty (recent work provably unpushed).
// AND the workspace tree is dirty (recent work provably unpushed). Per-root
// status files [D13]: the WORST entry decides, so one workspace's success
// can never mask another's failure.
try {
if (existsSync(pushStatusFile)) {
if (pushStatuses.length > 0) {
const { PUSH_STALE_MS } = await import('./hook.ts'); // hook.ts owns the threshold (single source)
const s = JSON.parse(readFileSync(pushStatusFile, 'utf8')) as { ts?: string; ok?: boolean; reason?: string };
const t = s.ts ? Date.parse(s.ts) : NaN;
const stale = Number.isFinite(t) && Date.now() - t > PUSH_STALE_MS;
let dirty = false;
if (ws) {
try {
dirty = execFileSync('git', ['-C', ws, 'status', '--porcelain'], {
stdio: ['ignore', 'pipe', 'ignore'], timeout: 10_000,
}).toString().trim() !== '';
} catch { dirty = false; }
}
if (s.ok === false) {
const failing = pushStatuses.filter((s) => s.ok === false);
if (failing.length > 0) {
const s = failing[0]!;
const target = s.repoRoot ?? ws ?? undefined;
const rest = failing.length > 1 ? ` [+${failing.length - 1} more workspace(s)]` : '';
checks.push({
name: 'bootstrap_push_health',
status: 'warn',
message: `last workspace push FAILED (${s.ts ?? 'unknown'}): ${s.reason ?? 'unknown'} — run \`gbrain sources push${ws ? ` --path ${ws}` : ''}\``,
message: `last workspace push FAILED${target ? ` for ${target}` : ''} (${s.ts ?? 'unknown'}): ${s.reason ?? 'unknown'}${rest} — run \`gbrain sources push${target ? ` --path ${target}` : ''}\``,
});
} else if (stale && dirty) {
checks.push({
name: 'bootstrap_push_health',
status: 'fail',
message: `last successful push ${s.ts} (>48h) with a DIRTY workspace tree — recent agent memory is unpushed [B4]. Run \`gbrain sources push --path ${ws}\`.`,
});
} else if (stale) {
checks.push({ name: 'bootstrap_push_health', status: 'warn', message: `last successful push ${s.ts} (>48h ago); tree clean — likely just idle` });
} else {
checks.push({ name: 'bootstrap_push_health', status: 'ok', message: `last push ok (${s.ts ?? 'unknown'})` });
const stamps = pushStatuses.map((s) => Date.parse(s.ts ?? '')).filter((t) => Number.isFinite(t));
const stalest = stamps.length > 0 ? Math.min(...stamps) : NaN;
const staleIso = Number.isFinite(stalest) ? new Date(stalest).toISOString() : 'unknown';
const stale = Number.isFinite(stalest) && Date.now() - stalest > PUSH_STALE_MS;
let dirty = false;
if (ws) {
try {
dirty = execFileSync('git', ['-C', ws, 'status', '--porcelain'], {
stdio: ['ignore', 'pipe', 'ignore'], timeout: 10_000,
}).toString().trim() !== '';
} catch { dirty = false; }
}
if (stale && dirty) {
checks.push({
name: 'bootstrap_push_health',
status: 'fail',
message: `last successful push ${staleIso} (>48h) with a DIRTY workspace tree — recent agent memory is unpushed [B4]. Run \`gbrain sources push --path ${ws}\`.`,
});
} else if (stale) {
checks.push({ name: 'bootstrap_push_health', status: 'warn', message: `last successful push ${staleIso} (>48h ago); tree clean — likely just idle` });
} else {
checks.push({ name: 'bootstrap_push_health', status: 'ok', message: `last push ok (${staleIso})` });
}
}
} else if (statusFilesOnDisk) {
// Files exist but none parsed — the tolerant reader skips corrupt
// records; doctor must not let that read as "no news is good news".
checks.push({ name: 'bootstrap_push_health', status: 'warn', message: 'push status unreadable' });
}
} catch {
checks.push({ name: 'bootstrap_push_health', status: 'warn', message: 'push-status.json unreadable' });
checks.push({ name: 'bootstrap_push_health', status: 'warn', message: 'push status unreadable' });
}
// 2b. Durability job [B7/D7]: presence + LIVENESS. A presence-only check
// certifies dead jobs as healthy (the autopilot-status failure mode), so
// this warns on plist-present-but-unloaded and stale pull logs. Only warns
// when the user actually consented to the job; containers/cloud sandboxes
// are expected to have none.
try {
if (ws !== null && receipt !== null) {
const { detectExecutionEnvironment } = await import('../core/execution-env.ts');
const envKind = detectExecutionEnvironment();
if (envKind !== 'local') {
// Answered BEFORE the subprocess probes — cloud/container doctor
// runs must not pay launchctl/crontab spawns for an answer that is
// discarded (no scheduler exists there by design).
checks.push({
name: 'bootstrap_durability_job',
status: 'ok',
message: `no scheduler in this environment (${envKind}) — expected; per-turn and session-end pushes cover persistence`,
});
} else {
const { durabilityJobStatus } = await import('../core/brain-repo-durability.ts');
const { readInterviewState } = await import('../core/bootstrap/interview.ts');
const sourceId = receipt.source_id ?? 'workspace';
const js = durabilityJobStatus(sourceId);
let consented = false;
try {
const iv = readInterviewState(ws);
consented = iv.ok && (iv.state.answers['PERSIST_CRON']?.value ?? '').toLowerCase() === 'yes';
} catch { consented = false; }
if (!consented) {
if (js.kind !== 'none') {
checks.push({ name: 'bootstrap_durability_job', status: 'ok', message: `${js.kind} pull job present (not required by consent — fine)` });
}
// no consent + no job → nothing to check; stay silent
} else if (js.kind === 'none') {
checks.push({
name: 'bootstrap_durability_job',
status: 'warn',
message: `background persistence was consented (PERSIST_CRON=yes) but no scheduled job exists — run \`gbrain sources harden ${sourceId}\``,
});
} else if (js.live === false) {
checks.push({
name: 'bootstrap_durability_job',
status: 'warn',
message: `${js.kind} job is on disk but NOT loaded — a dead job looks healthy to presence checks. Re-run \`gbrain sources harden ${sourceId}\` to reload it.`,
});
} else if (!js.wrapperPresent) {
checks.push({
name: 'bootstrap_durability_job',
status: 'warn',
message: `${js.kind} job exists but its wrapper script is missing — re-run \`gbrain sources harden ${sourceId}\``,
});
} else if (js.logFresh === false) {
checks.push({
name: 'bootstrap_durability_job',
status: 'warn',
message: `${js.kind} job present but the pull log is stale (no run within 2× the interval) — the job may be dead; re-run \`gbrain sources harden ${sourceId}\``,
});
} else if (js.kind === 'crontab' && js.logFresh === undefined) {
// The crontab LINE existing proves installation, not that the cron
// daemon runs it — with no pull log yet we can't claim liveness.
checks.push({ name: 'bootstrap_durability_job', status: 'ok', message: 'crontab pull job installed (no run logged yet — liveness confirmed once it first fires)' });
} else {
checks.push({ name: 'bootstrap_durability_job', status: 'ok', message: `${js.kind} pull job present and live` });
}
}
}
} catch { /* best-effort — durability probing never fails doctor */ }
// 3. One-live-serve / lock collision note. A live serve is the healthy
// shape (it provides hook IPC); the note names the v1 contract.
try {
+265 -4
View File
@@ -198,6 +198,22 @@ export interface EmbedResult {
failure_samples: string[];
/** True if this run was a dry-run. */
dryRun: boolean;
/**
* Chunkless-page safety net (`--stale` only): pages with non-empty
* content but zero `content_chunks` rows that this run chunked (or, in
* dryRun, would chunk) so their new NULL-embedding chunks fold into the
* SAME pass. 0 on a healthy brain. Additive field see
* `ChunklessPageRow` for the detection rationale.
*/
chunkless_pages_healed: number;
/**
* Set when a single-flight run did NO work because another backfill holds
* the per-source embed lock. A hard-killed (SIGKILL/crash) run leaves its
* lock behind for up to EMBED_BACKFILL_LOCK_TTL_MIN callers that promise
* "re-run to resume" (migrate embeddings) use this to say so instead of
* misreporting embed failures.
*/
lock_skipped?: boolean;
/**
* E1 (paced-backfill): end-of-run pacing telemetry. Present ONLY when pacing
* was active (enabled bundle). The number the operator could not get from an
@@ -317,6 +333,7 @@ export async function runEmbedCore(engine: BrainEngine, opts: EmbedOpts): Promis
failures: 0,
failure_samples: [],
dryRun: !!opts.dryRun,
chunkless_pages_healed: 0,
};
if (opts.slugs && opts.slugs.length > 0) {
@@ -375,6 +392,7 @@ export async function runEmbedCore(engine: BrainEngine, opts: EmbedOpts): Promis
try { await h.release(); } catch { /* best-effort */ }
}
serr(` [embed] another backfill is already running for source "${sid}"; skipping (single-flight).`);
result.lock_skipped = true;
return result;
}
sfLocks.push(lock);
@@ -529,6 +547,7 @@ export async function runEmbed(engine: BrainEngine, args: string[]): Promise<Emb
return {
embedded: 0, skipped: 0, would_embed: 0, total_chunks: 0,
pages_processed: 0, failures: 0, failure_samples: [], dryRun: false,
chunkless_pages_healed: 0,
};
}
@@ -988,6 +1007,201 @@ async function embedAll(
}
}
/**
* Chunkless-page safety net for `embed --stale`. `listStaleChunks` /
* `countStaleChunks` only ever look at `content_chunks` rows where
* `embedding IS NULL` a page written directly via `putPage` that never
* went through chunking (e.g. an enrichment-generated entity stub) has NO
* chunk row at all, so it is invisible to that scan forever, even after
* unlimited `embed --stale` runs.
*
* This sweep finds pages with non-empty content (`compiled_truth` and/or
* `timeline` both are chunked independently, mirroring `embedPage`'s
* chunkless branch) and zero `content_chunks` rows
* (`engine.listChunklessPagesWithContent`, which already excludes
* quarantined + embed_skip pages both intentionally chunkless). The new
* chunk rows land with `embedding = NULL`, so they flow into the SAME
* `embed --stale` pass via the existing cursor below no separate embed
* step needed here.
*
* dryRun chunks locally (a pure, in-memory operation) to report an
* accurate count without writing anything, matching embedPage's dry-run
* contract (including `pages_processed`, which embedPage's own dry-run
* branch increments for exactly this "examined, didn't write" case).
*
* Race note (review catch, three rounds ACCEPTED RESIDUAL RISK, not
* fully closed): between listing a page and writing its chunks, a
* concurrent writer (sync, another `put_page`) could change or chunk the
* SAME page. Two mitigations, both bounded full atomicity (a
* transaction/version-guarded conditional write inside `upsertChunks`)
* would need a new engine primitive shared by every `upsertChunks` caller,
* which is out of scope for a chunkless-page safety net:
* 1. Immediately before writing, re-fetch the LIVE page via `getPage`
* and build `inputs` from ITS CURRENT content, not the batch-list
* snapshot closes the "content changed but still chunkless"
* sub-case, not just the "chunks appeared" one.
* 2. Re-check `getChunks` right after that same fetch skip (don't
* overwrite) if chunks now exist AT THE TIME OF THE CHECK.
* What this does NOT close: a writer that inserts chunks in the gap
* BETWEEN step 2's check and the `upsertChunks` call immediately below it
* (no intervening `await` other than that one call, but `upsertChunks`
* itself is not conditioned on the check this is still check-then-write,
* not compare-and-swap) can still have its chunks overwritten HONESTLY:
* `upsertChunks` treats its input as the full desired chunk set for that
* page and deletes any existing chunk_index absent from it, so a
* concurrent writer's chunks landing in that exact gap CAN be replaced
* with this sweep's stale-content chunks (embedding NULL). This is the
* SAME check-then-write window `embedPage`'s existing single-page
* chunkless branch already ships with today (that branch doesn't even
* have step 2's re-check) no new race CLASS is introduced, and the
* window here is a single sequential getPage+getChunks+upsertChunks
* instead of spanning a whole batch. The blast radius is bounded: the
* page is NOT deleted or corrupted, just re-chunked from a stale
* snapshot, and the NEXT write to that page (sync, another edit) that
* actually chunks it restores correct content this sweep's own
* predicate is idempotent and doesn't compound the drift. Closing this
* fully (true atomicity) is tracked as a follow-up, not blocking this
* safety net.
*
* Per-page failure isolation (review catch): one malformed/oversized
* chunkless page must not abort the sweep and, with it, the entire
* `--stale` run before the normal NULL-embedding pass even starts that
* would make the safety net WORSE than the bug it fixes. Each page's
* work is try/caught; a failure is recorded (`EmbedResult.failures` +
* `failure_samples`, same convention as every other embed failure path)
* and the sweep moves on.
*
* Bounded, keyset-paginated (like listStalePagesForExtraction) a safety
* net for a rare drift case, not the primary bulk-chunking path. `BATCH_SIZE`
* is deliberately small (unlike the 2000-chunk-row default elsewhere in
* this file): each row here carries a FULL page body (`compiled_truth` +
* `timeline`), so a large batch of large pages is a real memory/latency
* concern the metadata-only `listStaleChunks` rows never had (review
* catch). It still respects the caller's pacer (no-op when pacing is off)
* and a soft wall-clock cap (`GBRAIN_EMBED_TIME_BUDGET_MS`) so a
* pathologically large damaged brain can't run this sweep unbounded it
* heals what it can and reports the rest for the next `embed --stale` run
* (the SQL predicate is idempotent; nothing here requires finishing in one
* pass). `startedAt` is shared with the caller's overall run clock (review
* catch) healing and the main stale loop draw from ONE combined budget
* window, not two independent 30-minute ones. `catchUp` mirrors the main
* loop's own `--catch-up` handling: removes the cap entirely (the keyset
* cursor still terminates on its own; `signal` remains the abort path).
*/
async function healChunklessPages(
engine: BrainEngine,
sourceId: string | undefined,
dryRun: boolean,
result: EmbedResult,
quiet: boolean | undefined,
signal: AbortSignal | undefined,
pacer: DbPacer | undefined,
startedAt: number,
catchUp: boolean,
): Promise<void> {
const BATCH_SIZE = 50;
const BUDGET_MS: number | null = catchUp
? null
: parseInt(process.env.GBRAIN_EMBED_TIME_BUDGET_MS || `${30 * 60 * 1000}`, 10);
const activePacer = pacer ?? createNoopPacer();
let afterPageId: number | undefined;
let pagesHealed = 0;
let budgetExceeded = false;
const buildInputs = (compiledTruth: string, timeline: string): ChunkInput[] => {
const inputs: ChunkInput[] = [];
if (compiledTruth.trim()) {
for (const c of chunkText(compiledTruth)) {
inputs.push({ chunk_index: inputs.length, chunk_text: c.text, chunk_source: 'compiled_truth' });
}
}
if (timeline.trim()) {
for (const c of chunkText(timeline)) {
inputs.push({ chunk_index: inputs.length, chunk_text: c.text, chunk_source: 'timeline' });
}
}
return inputs;
};
// BUDGET_MS === null means catch-up: no wall-clock cap on this sweep,
// mirroring the main stale loop's own --catch-up handling below.
const overBudget = (): boolean => BUDGET_MS != null && Date.now() - startedAt > BUDGET_MS;
// eslint-disable-next-line no-constant-condition
while (true) {
if (isAborted(signal)) break;
if (overBudget()) { budgetExceeded = true; break; }
const batch = await observed(activePacer, () => engine.listChunklessPagesWithContent({
batchSize: BATCH_SIZE,
...(afterPageId != null && { afterPageId }),
...(sourceId && { sourceId }),
}));
if (batch.length === 0) break;
afterPageId = batch[batch.length - 1].id;
for (const page of batch) {
if (isAborted(signal)) break;
if (overBudget()) { budgetExceeded = true; break; }
try {
if (dryRun) {
// dryRun never writes, so there's no live-refetch race to close —
// chunk the listed snapshot directly (matches embedPage's own
// dry-run, which chunks whatever getPage returned at call time).
const inputs = buildInputs(page.compiled_truth, page.timeline);
// Whitespace-only content (SQL prefilter is `<> ''`, not
// trim-aware) chunks to nothing — matches embedPage's contract.
if (inputs.length === 0) continue;
result.total_chunks += inputs.length;
result.would_embed += inputs.length;
result.pages_processed++;
pagesHealed++;
continue;
}
// Re-fetch the LIVE page + re-check chunks immediately before
// writing (see race note above): chunk CURRENT content, and skip
// rather than clobber if a concurrent writer already chunked this
// page since we listed it.
const [livePage, stillChunkless] = await Promise.all([
observed(activePacer, () => engine.getPage(page.slug, { sourceId: page.source_id })),
observed(activePacer, () => engine.getChunks(page.slug, { sourceId: page.source_id })),
]);
if (!livePage || stillChunkless.length > 0) continue;
const inputs = buildInputs(livePage.compiled_truth, livePage.timeline);
if (inputs.length === 0) continue;
await observed(activePacer, () =>
engine.upsertChunks(page.slug, inputs, { sourceId: page.source_id }),
);
pagesHealed++;
try {
await activePacer.pace(signal);
} catch (e) {
if (!(e instanceof AbortError)) throw e;
}
} catch (e) {
if (isAborted(signal)) break;
recordFailure(result, 1, page.slug, e);
serr(`\n [embed] chunkless-page heal failed for ${page.slug}: ${e instanceof Error ? e.message : e}`);
}
}
if (budgetExceeded || batch.length < BATCH_SIZE) break;
}
result.chunkless_pages_healed = pagesHealed;
if (pagesHealed > 0 && !quiet) {
if (dryRun) {
serr(`[embed] [dry-run] would chunk ${pagesHealed} page(s) with non-empty content but zero content_chunks rows`);
} else {
serr(`[embed] chunked ${pagesHealed} page(s) that had non-empty content but zero content_chunks rows (embedding them in this pass)`);
}
}
if (budgetExceeded && !quiet) {
serr(`[embed] chunkless-page sweep hit its time budget (${BUDGET_MS}ms) with more pages left; re-run embed --stale to continue healing them`);
}
}
/**
* SQL-side stale path: replaces the listPages + per-page getChunks
* walk with a count + slug-grouped SELECT. Preserves the existing
@@ -1028,11 +1242,38 @@ async function embedAllStale(
signature?: string,
externalSignal?: AbortSignal,
) {
// Shared wall-clock anchor (review catch): the healing sweep below and the
// main stale loop's own budget timer (further down) both measure against
// this SAME start time, so a run's total wall-clock spend stays capped at
// ONE `GBRAIN_EMBED_TIME_BUDGET_MS` window instead of summing two
// independent 30-minute budgets.
const overallStartedAt = Date.now();
// D7: thread sourceId so source-scoped runs only count + visit
// that source's NULL embeddings.
const sourceOpt = sourceId ? { sourceId } : undefined;
const includeNullSig = !!staleOpts?.includeNullSignature;
// Chunkless-page safety net: pre-flight count mirrors the countStaleChunks
// short-circuit just below — a healthy brain pays one extra SELECT
// count(*) and does no further work. Only when pages are actually found
// do we pay for the keyset-paginated chunk sweep. Chunking here (before
// countStaleChunks) means any newly-written NULL-embedding chunks flow
// into the SAME pass via the existing cursor.
const chunklessCount = await engine.countChunklessPagesWithContent(sourceOpt);
if (chunklessCount > 0) {
await healChunklessPages(
engine, sourceId, dryRun, result, staleOpts?.quiet, externalSignal, staleOpts?.pacer,
overallStartedAt, !!staleOpts?.catchUp,
);
}
// Review catch: an abort during healing must stop the run HERE, before
// falling through into invalidateStaleSignatureEmbeddings below (which —
// pre-existing, unchanged by this PR — does not itself check
// externalSignal). Without this, a caller-cancelled run could still NULL
// out signature-drifted embeddings and exit, leaving retrieval degraded.
if (isAborted(externalSignal)) return;
// v0.41.31: re-embed pages whose embedding_signature drifted (model/dims
// swap). dry-run must NOT mutate, so it counts signature-stale via the
// widened predicate; a live run NULLs them first so the existing
@@ -1085,7 +1326,15 @@ async function embedAllStale(
if (staleCount === 0) {
if (!staleOpts?.quiet) {
if (dryRun) {
slog('[dry-run] Would embed 0 chunks (0 stale found)');
// dryRun never writes, so a healed-but-hypothetical chunkless page's
// chunks never land in content_chunks and staleCount can't see them
// — report result.would_embed (already includes them) instead of a
// bare "0 chunks" that would contradict the returned EmbedResult.
if (result.would_embed > 0) {
slog(`[dry-run] Would embed ${result.would_embed} chunks (0 stale found; ${result.chunkless_pages_healed} chunkless page(s) would be chunked)`);
} else {
slog('[dry-run] Would embed 0 chunks (0 stale found)');
}
} else {
slog('Embedded 0 chunks (0 stale found)');
}
@@ -1101,7 +1350,16 @@ async function embedAllStale(
// made `embed.pages` claim total:1 next to a summary naming a much larger
// stale count. docs/progress-events.md allows omitting `total` when it is
// not known up front; it does not allow asserting a wrong one.
if (!staleOpts?.quiet) slog(`[dry-run] Would embed ${staleCount} stale chunks`);
//
// Log result.would_embed (staleCount + any chunkless-page-healing
// contribution from above), not the bare staleCount — otherwise this
// line understates the total whenever chunkless pages were also found.
if (!staleOpts?.quiet) {
const chunklessNote = result.chunkless_pages_healed > 0
? `, including ${result.chunkless_pages_healed} chunkless page(s)`
: '';
slog(`[dry-run] Would embed ${result.would_embed} stale chunks${chunklessNote}`);
}
return;
}
@@ -1132,9 +1390,12 @@ async function embedAllStale(
? null
: parseInt(process.env.GBRAIN_EMBED_TIME_BUDGET_MS || `${30 * 60 * 1000}`, 10);
const budgetController = new AbortController();
const budgetStart = Date.now();
// Shares overallStartedAt with the chunkless-page healing sweep above
// (review catch) so the two phases draw from ONE combined budget window
// instead of each getting a fresh 30 minutes.
const budgetStart = overallStartedAt;
let budgetTimer = BUDGET_MS != null
? setTimeout(() => budgetController.abort(), BUDGET_MS)
? setTimeout(() => budgetController.abort(), Math.max(0, budgetStart + BUDGET_MS - Date.now()))
: undefined;
// E-4 (paced-backfill): the budget measures WORK, not waiting. After each
// batch, re-arm the timer to fire at start + BUDGET + total-paced-sleep, so a
+24 -9
View File
@@ -16,13 +16,14 @@
*/
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
import { dirname, join } from 'node:path';
import { execSync } from 'node:child_process';
import { flushThenExit } from '../core/cli-force-exit.ts';
import { cliOptsToProgressOptions, getCliOptions } from '../core/cli-options.ts';
import { createProgress } from '../core/progress.ts';
import { buildMetricGlossaryMeta } from '../core/eval/metric-glossary.ts';
import { isAvailable } from '../core/ai/gateway.ts';
import { findGbrainRoot } from '../core/skillpack/bundle.ts';
import { FixtureValidationError, loadCorpus } from '../eval/brainbench/fixtures.ts';
import { runBrainBench } from '../eval/brainbench/harness.ts';
import {
@@ -44,9 +45,19 @@ import {
type HarnessName,
} from '../eval/brainbench/types.ts';
export const DEFAULT_FIXTURES_DIR = 'evals/brainbench/fixtures';
export const DEFAULT_GOLD_DIR = 'evals/brainbench/gold';
export const DEFAULT_BASELINE_PATH = 'evals/brainbench/baselines/main.json';
const DEFAULT_FIXTURES_RELATIVE = 'evals/brainbench/fixtures';
const DEFAULT_GOLD_RELATIVE = 'evals/brainbench/gold';
const DEFAULT_BASELINE_RELATIVE = 'evals/brainbench/baselines/main.json';
const GBRAIN_ROOT = findGbrainRoot();
function resolveBundledPath(relativePath: string): string {
return GBRAIN_ROOT ? join(GBRAIN_ROOT, relativePath) : relativePath;
}
export const DEFAULT_FIXTURES_DIR = resolveBundledPath(DEFAULT_FIXTURES_RELATIVE);
export const DEFAULT_GOLD_DIR = resolveBundledPath(DEFAULT_GOLD_RELATIVE);
export const DEFAULT_BASELINE_PATH = resolveBundledPath(DEFAULT_BASELINE_RELATIVE);
const DEFAULT_LLM_BUDGET_USD = 5;
function usage(): void {
@@ -55,8 +66,8 @@ function usage(): void {
`Cross-harness memory conformance suite. Hermetic by default: in-memory\n` +
`PGLite, no API keys, no LLM calls. See docs/eval/BRAINBENCH.md.\n\n` +
`Options:\n` +
` --fixtures DIR Fixture corpus (default: ${DEFAULT_FIXTURES_DIR}).\n` +
` --gold DIR Sealed gold dir (default: ${DEFAULT_GOLD_DIR}).\n` +
` --fixtures DIR Fixture corpus (default: bundled ${DEFAULT_FIXTURES_RELATIVE}).\n` +
` --gold DIR Sealed gold dir (default: bundled ${DEFAULT_GOLD_RELATIVE}).\n` +
` --harness a,b | all Harness seams to grade (default: all).\n` +
` --suite a,b | all Suites to run (default: all).\n` +
` --include-holdout Score holdout fixtures too (published-run mode).\n` +
@@ -64,8 +75,8 @@ function usage(): void {
` --out FILE Write the full JSON result to FILE (canonical CI artifact).\n` +
` --compare BASE [CURRENT] Gate against BASE baseline. With CURRENT: pure\n` +
` file-vs-file diff, no run. CI passes MAIN's baseline\n` +
` as BASE (git show origin/master:${DEFAULT_BASELINE_PATH}).\n` +
` --committed-baseline FILE Bless-mode verification target (default: ${DEFAULT_BASELINE_PATH}).\n` +
` as BASE (git show origin/master:${DEFAULT_BASELINE_RELATIVE}).\n` +
` --committed-baseline FILE Bless-mode verification target (default: bundled ${DEFAULT_BASELINE_RELATIVE}).\n` +
` --update-baseline [FILE] Write this run as the canonical committed baseline.\n` +
` --justification "reason" Recorded in the baseline written by --update-baseline\n` +
` (REQUIRED by the gate when blessing a regression).\n` +
@@ -211,7 +222,11 @@ function parseArgs(argv: string[]): Args | { usageError: string } {
function gitHeadSha(): string {
try {
return execSync('git rev-parse HEAD', { encoding: 'utf-8' }).trim();
return execSync('git rev-parse HEAD', {
cwd: GBRAIN_ROOT ?? undefined,
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'ignore'],
}).trim();
} catch {
return 'unknown';
}
+12 -3
View File
@@ -108,7 +108,11 @@ export async function runExport(engine: BrainEngine, args: string[]) {
let exported = 0;
for (const page of pages) {
const tags = await engine.getTags(page.slug);
// Slugs are unique per source, not brain-wide, so both sidecar reads are
// pinned to the page's own source. Unscoped, `getTags` falls back to
// `source_id = 'default'` and stamps the default source's tags onto a
// same-slug page from another source (dropping its real ones).
const tags = await engine.getTags(page.slug, { sourceId: page.source_id });
const md = serializeMarkdown(
page.frontmatter,
page.compiled_truth,
@@ -120,8 +124,13 @@ export async function runExport(engine: BrainEngine, args: string[]) {
mkdirSync(dirname(filePath), { recursive: true });
writeFileSync(filePath, md);
// Export raw data as sidecar JSON
const rawData = await engine.getRawData(page.slug);
// Export raw data as sidecar JSON. Unscoped, this matches the slug in
// EVERY source and the loop below merges the rows into one sidecar keyed
// by `rd.source`, so another source's raw data silently overwrites this
// page's own on a key collision.
const rawData = await engine.getRawData(page.slug, undefined, {
sourceId: page.source_id,
});
if (rawData.length > 0) {
const slugParts = page.slug.split('/');
const rawDir = join(outDir, ...slugParts.slice(0, -1), '.raw');
+44 -4
View File
@@ -81,8 +81,10 @@ const BATCH_SIZE = 100;
const STALE_BATCH_SIZE = Math.max(1, Number(process.env.GBRAIN_EXTRACT_STALE_BATCH) || 25);
// v0.42.7: wall-clock budget for one `extract --stale` invocation (default
// 30 min). `--catch-up` removes the cap (loops until 0 stale). Mirrors
// embedAllStale's time-budget shape.
const STALE_TIME_BUDGET_MS = Math.max(1000, Number(process.env.GBRAIN_EXTRACT_TIME_BUDGET_MS) || 30 * 60 * 1000);
// embedAllStale's time-budget shape. Exported so the #2849 deferred-sweep
// submitters (sync's size-gate defer branch + the jobs continuation chain)
// derive their job timeout_ms from the SAME budget instead of hardcoding.
export const STALE_TIME_BUDGET_MS = Math.max(1000, Number(process.env.GBRAIN_EXTRACT_TIME_BUDGET_MS) || 30 * 60 * 1000);
/**
* v0.42.7 (#1696): best-effort extraction stamp for the source-correct write
@@ -488,15 +490,53 @@ export async function extractLinksFromFile(
// --- Timeline extraction ---
/**
* Index of the first dash (, , -) that can serve as the Source Summary
* delimiter: it must have whitespace on both sides and sit outside every
* markdown-link span. Hyphens inside link targets
* (`../people/alice-example.md`) and dashes inside link labels
* (`[Deals — Q1 Review](...)`) are content, not delimiters splitting on
* them shatters one entry into two fragments whose halves re-insert on
* every sync (the (page_id, date, summary, source) uniqueness sees each
* fragment shape as a new row). Returns -1 when the line has no delimiter.
*/
function findDelimiterOutsideLinks(text: string): number {
let depth = 0;
for (let i = 0; i < text.length; i++) {
const c = text[i];
if (c === '[' || c === '(') depth++;
else if (c === ']' || c === ')') { if (depth > 0) depth--; }
else if (
depth === 0 &&
(c === '—' || c === '' || c === '-') &&
i > 0 && /\s/.test(text[i - 1]) &&
i + 1 < text.length && /\s/.test(text[i + 1])
) {
return i;
}
}
return -1;
}
/** Extract timeline entries from markdown content */
export function extractTimelineFromContent(content: string, slug: string): ExtractedTimelineEntry[] {
const entries: ExtractedTimelineEntry[] = [];
// Format 1: Bullet — - **YYYY-MM-DD** | Source — Summary
const bulletPattern = /^-\s+\*\*(\d{4}-\d{2}-\d{2})\*\*\s*\|\s*(.+?)\s*[—–-]\s*(.+)$/gm;
// The delimiter search is link-aware (see findDelimiterOutsideLinks); a
// bullet with no delimiter (e.g. an auto-generated backlink line
// `- **date** | Referenced in [X](y.md)`) is kept whole as the summary
// rather than dropped or fragmented.
const bulletPattern = /^-\s+\*\*(\d{4}-\d{2}-\d{2})\*\*\s*\|\s*(.+)$/gm;
let match;
while ((match = bulletPattern.exec(content)) !== null) {
entries.push({ slug, date: match[1], source: match[2].trim(), summary: match[3].trim() });
const rest = match[2].trim();
const at = findDelimiterOutsideLinks(rest);
if (at >= 0) {
entries.push({ slug, date: match[1], source: rest.slice(0, at).trim(), summary: rest.slice(at + 1).trim() });
} else {
entries.push({ slug, date: match[1], source: 'markdown', summary: rest });
}
}
// Format 2: Header — ### YYYY-MM-DD — Title
+324 -5
View File
@@ -1,21 +1,24 @@
/**
* gbrain friction friction reporter CLI.
*
* Four subcommands in v1 (analytical/clustering ones move to v1.1):
* Five subcommands (remaining analytical ones trend, migration-stub stay v1.1):
* gbrain friction log Append a friction or delight entry
* gbrain friction render Render a run as markdown or JSON
* gbrain friction list List recent runs with counts
* gbrain friction summary Side-by-side friction + delight summary
* gbrain friction diff Compare two runs (or agents): unique-to-each + shared-but-changed
*
* Subcommands stay thin ( ~30 LOC each). Core logic lives in src/core/friction.ts.
* Subcommands stay thin ( ~30 LOC each). Reader/writer/redaction logic lives
* in src/core/friction.ts; the diff computation lives here (it is CLI-only).
*
* The CLI is dispatched from src/cli.ts. See `gbrain friction --help`.
*/
import { existsSync } from 'fs';
import {
logFriction, readFriction, listRuns, renderReport, renderSummary,
activeRunId, frictionFile,
type FrictionKind, type FrictionSeverity,
activeRunId, frictionFile, frictionDir, redactEntry,
type FrictionKind, type FrictionSeverity, type FrictionEntry, type ReadResult,
} from '../core/friction.ts';
const VALID_KINDS = new Set<FrictionKind>(['friction', 'delight', 'phase-marker', 'interrupted']);
@@ -28,6 +31,7 @@ export function runFriction(args: string[]): number {
case 'render': return cmdRender(rest);
case 'list': return cmdList(rest);
case 'summary': return cmdSummary(rest);
case 'diff': return cmdDiff(rest);
case undefined:
case '--help':
case '-h':
@@ -150,6 +154,317 @@ function cmdSummary(args: string[]): number {
}
}
// ---------------------------------------------------------------------------
// diff
// ---------------------------------------------------------------------------
/**
* Entry identity for diffing: (kind, phase, normalized message prefix)
* lowercase, whitespace-collapsed, digit runs collapsed (durations, counts,
* and tempdir suffixes would otherwise make the same friction land in
* "unique to each" across runs), first 80 chars, with redaction applied
* FIRST (the caller redacts via redactEntry). Kind IS identity: a delight
* and a friction with the same text are different findings, and a
* delightfriction flip must surface, never compare equal. Severity is
* deliberately EXCLUDED from identity it is the compared attribute, as a
* PER-SEVERITY MULTISET (two errors + one nit vs one error + two nits is a
* reported difference even though the severity sets and totals match).
*/
const IDENTITY_PREFIX_CHARS = 80;
interface DiffIdentityRecord {
kind: string;
phase: string;
/** Redacted message of the first occurrence (display sample). */
message: string;
count: number;
/** Unique severities, sorted (display); counts live in severity_counts. */
severities: string[];
severity_counts: Record<string, number>;
}
interface DiffChangedRecord {
kind: string;
phase: string;
message: string;
base_count: number;
compare_count: number;
base_severities: string[];
compare_severities: string[];
base_severity_counts: Record<string, number>;
compare_severity_counts: Record<string, number>;
count_changed: boolean;
severity_changed: boolean;
}
interface DiffRunBanner {
run_id: string;
agent?: string;
scenario?: string;
gbrain_version?: string;
interrupted: boolean;
/** Malformed JSONL lines skipped by the reader (surfaced, never hidden). */
malformed: number;
}
export interface FrictionDiffResult {
base: string;
compare: string;
banner: { base: DiffRunBanner; compare: DiffRunBanner; warnings: string[] };
unique_to_base: DiffIdentityRecord[];
unique_to_compare: DiffIdentityRecord[];
changed: DiffChangedRecord[];
}
/**
* Resolve a run spec: an exact run-id wins; otherwise treat the spec as an
* agent name and pick the LATEST run (listRuns is mtime-sorted, newest first)
* whose entries carry that agent the run-start marker stamps agent on clean
* runs, and any agent-stamped entry also counts. Returns undefined if nothing
* matches.
*/
export function resolveRunSpec(spec: string): string | undefined {
try {
if (existsSync(frictionFile(spec))) return spec;
} catch { /* spec has characters a run-id can't; fall through to agent-name resolution */ }
for (const run of listRuns()) {
try {
const { entries } = readFriction(run.runId);
if (entries.some(e => e.agent === spec)) return run.runId;
} catch { /* unreadable file; skip */ }
}
return undefined;
}
function normalizeForIdentity(message: string): string {
return message.toLowerCase().replace(/\d+/g, '#').replace(/\s+/g, ' ').trim().slice(0, IDENTITY_PREFIX_CHARS);
}
interface IdentityAccum { kind: string; phase: string; message: string; count: number; severities: Map<string, number> }
/** Sorted-key plain object from a severity count map (deterministic JSON). */
function severityCounts(m: Map<string, number>): Record<string, number> {
return Object.fromEntries([...m.entries()].sort(([a], [b]) => a.localeCompare(b)));
}
/** Diff operates ONLY on kind friction|delight; markers/interruptions feed the banner. */
function collectIdentities(entries: FrictionEntry[]): Map<string, IdentityAccum> {
const map = new Map<string, IdentityAccum>();
for (const raw of entries) {
// Entries from older writers may omit kind; they are friction by contract.
const kind = raw.kind ?? 'friction';
if (kind !== 'friction' && kind !== 'delight') continue;
const e = redactEntry(raw);
const key = `${kind}\u0000${e.phase}\u0000${normalizeForIdentity(e.message)}`;
let acc = map.get(key);
if (!acc) {
acc = { kind, phase: e.phase, message: e.message, count: 0, severities: new Map() };
map.set(key, acc);
}
acc.count++;
if (e.severity) acc.severities.set(e.severity, (acc.severities.get(e.severity) ?? 0) + 1);
}
return map;
}
function toIdentityRecord(acc: IdentityAccum): DiffIdentityRecord {
return {
kind: acc.kind,
phase: acc.phase,
message: acc.message,
count: acc.count,
severities: [...acc.severities.keys()].sort(),
severity_counts: severityCounts(acc.severities),
};
}
function bannerFor(runId: string, read: ReadResult): DiffRunBanner {
const start = read.entries.find(e => e.kind === 'phase-marker' && e.marker === 'start');
const agent = start?.agent ?? read.entries.find(e => e.agent)?.agent;
return {
run_id: runId,
agent,
scenario: start?.scenario,
gbrain_version: start?.gbrain_version ?? read.entries[0]?.gbrain_version,
interrupted: read.entries.some(e => e.kind === 'interrupted'),
malformed: read.malformed,
};
}
/** Compute the diff between two resolved run-ids. Throws on read errors. */
export function computeFrictionDiff(baseRunId: string, compareRunId: string): FrictionDiffResult {
const baseRead = readFriction(baseRunId);
const compareRead = readFriction(compareRunId);
const baseBanner = bannerFor(baseRunId, baseRead);
const compareBanner = bannerFor(compareRunId, compareRead);
const warnings: string[] = [];
if ((baseBanner.scenario ?? '') !== (compareBanner.scenario ?? '')) {
warnings.push(`scenario differs: ${baseRunId} ran ${baseBanner.scenario ?? '(unknown)'}, ${compareRunId} ran ${compareBanner.scenario ?? '(unknown)'} — entries may not be comparable`);
}
if ((baseBanner.gbrain_version ?? '') !== (compareBanner.gbrain_version ?? '')) {
warnings.push(`gbrain version differs: ${baseRunId} ran ${baseBanner.gbrain_version ?? '(unknown)'}, ${compareRunId} ran ${compareBanner.gbrain_version ?? '(unknown)'}`);
}
const baseIds = collectIdentities(baseRead.entries);
const compareIds = collectIdentities(compareRead.entries);
const uniqueToBase: DiffIdentityRecord[] = [];
const uniqueToCompare: DiffIdentityRecord[] = [];
const changed: DiffChangedRecord[] = [];
for (const [key, b] of baseIds) {
const c = compareIds.get(key);
if (!c) { uniqueToBase.push(toIdentityRecord(b)); continue; }
const bCounts = severityCounts(b.severities);
const cCounts = severityCounts(c.severities);
const countChanged = b.count !== c.count;
// severity_changed = the per-severity DISTRIBUTION SHAPE changed — a new
// severity appeared/disappeared or the mix redistributed (2×error+1×nit →
// 1×error+2×nit, which the unique-severity set hides). Uniform scaling
// (1×error → 10×error) is purely a count change and count_changed already
// reports it. Integer cross-multiplication keeps the proportion test
// exact.
const bTotal = Object.values(bCounts).reduce((a, n) => a + n, 0);
const cTotal = Object.values(cCounts).reduce((a, n) => a + n, 0);
let severityChanged = (bTotal === 0) !== (cTotal === 0);
if (!severityChanged) {
for (const s of new Set([...Object.keys(bCounts), ...Object.keys(cCounts)])) {
if ((bCounts[s] ?? 0) * cTotal !== (cCounts[s] ?? 0) * bTotal) { severityChanged = true; break; }
}
}
if (countChanged || severityChanged) {
changed.push({
kind: b.kind, phase: b.phase, message: b.message,
base_count: b.count, compare_count: c.count,
base_severities: Object.keys(bCounts), compare_severities: Object.keys(cCounts),
base_severity_counts: bCounts, compare_severity_counts: cCounts,
count_changed: countChanged, severity_changed: severityChanged,
});
}
}
for (const [key, c] of compareIds) {
if (!baseIds.has(key)) uniqueToCompare.push(toIdentityRecord(c));
}
const byPhaseThenMessage = (a: { phase: string; message: string }, b: { phase: string; message: string }) =>
a.phase.localeCompare(b.phase) || a.message.localeCompare(b.message);
uniqueToBase.sort(byPhaseThenMessage);
uniqueToCompare.sort(byPhaseThenMessage);
changed.sort(byPhaseThenMessage);
return {
base: baseRunId,
compare: compareRunId,
banner: { base: baseBanner, compare: compareBanner, warnings },
unique_to_base: uniqueToBase,
unique_to_compare: uniqueToCompare,
changed,
};
}
function groupByPhase<T extends { phase: string }>(records: T[]): Map<string, T[]> {
const map = new Map<string, T[]>();
for (const r of records) {
if (!map.has(r.phase)) map.set(r.phase, []);
map.get(r.phase)!.push(r);
}
return map;
}
function fmtSeverityCounts(counts: Record<string, number>): string {
const parts = Object.entries(counts).map(([s, n]) => (n > 1 ? `${s}×${n}` : s));
return parts.length > 0 ? parts.join('/') : '(none)';
}
/** Render the diff as markdown. Sections are labeled by run-id the diff is
* an instrument, not a judge; it never attributes blame to either side. */
function renderDiff(diff: FrictionDiffResult): string {
const lines: string[] = [];
lines.push(`# Friction diff — base \`${diff.base}\` vs compare \`${diff.compare}\``);
lines.push('');
for (const side of [diff.banner.base, diff.banner.compare]) {
const bits = [
`agent=${side.agent ?? '(unknown)'}`,
`scenario=${side.scenario ?? '(unknown)'}`,
`gbrain=${side.gbrain_version ?? '(unknown)'}`,
];
if (side.interrupted) bits.push('interrupted');
if (side.malformed > 0) bits.push(`${side.malformed} malformed line(s) skipped`);
lines.push(`- \`${side.run_id}\`: ${bits.join(' · ')}`);
}
lines.push('');
for (const w of diff.banner.warnings) lines.push(`> ⚠ WARN: ${w}`);
if (diff.banner.warnings.length > 0) lines.push('');
if (diff.unique_to_base.length === 0 && diff.unique_to_compare.length === 0 && diff.changed.length === 0) {
lines.push('No differences.');
lines.push('');
}
const renderIdentitySection = (title: string, records: DiffIdentityRecord[]) => {
lines.push(`## ${title} (${records.length})`);
lines.push('');
if (records.length === 0) { lines.push('(none)'); lines.push(''); return; }
for (const [phase, rs] of groupByPhase(records)) {
lines.push(`### \`${phase}\``);
lines.push('');
for (const r of rs) {
const sev = r.severities.length > 0 ? `[${r.severities.join('/')}] ` : '';
const kind = r.kind === 'delight' ? '[delight] ' : '';
const count = r.count > 1 ? ` ×${r.count}` : '';
lines.push(`- ${kind}${sev}${r.message}${count}`);
}
lines.push('');
}
};
renderIdentitySection(`Unique to \`${diff.compare}\``, diff.unique_to_compare);
renderIdentitySection(`Unique to \`${diff.base}\``, diff.unique_to_base);
lines.push(`## Shared but changed (${diff.changed.length})`);
lines.push('');
if (diff.changed.length === 0) { lines.push('(none)'); lines.push(''); }
for (const [phase, rs] of groupByPhase(diff.changed)) {
lines.push(`### \`${phase}\``);
lines.push('');
for (const r of rs) {
const deltas: string[] = [];
if (r.severity_changed) deltas.push(`severity ${fmtSeverityCounts(r.base_severity_counts)}${fmtSeverityCounts(r.compare_severity_counts)}`);
if (r.count_changed) deltas.push(`count ${r.base_count}${r.compare_count}`);
const kind = r.kind === 'delight' ? '[delight] ' : '';
lines.push(`- ${kind}${r.message}${deltas.join('; ')}`);
}
lines.push('');
}
return lines.join('\n');
}
function cmdDiff(args: string[]): number {
const flags = parseFlags(args);
const baseSpec = flags.string('--base');
const compareSpec = flags.string('--compare');
if (!baseSpec || !compareSpec) {
console.error('usage: gbrain friction diff --base <run-or-agent> --compare <run-or-agent> [--json]');
return 2;
}
const baseRun = resolveRunSpec(baseSpec);
const compareRun = resolveRunSpec(compareSpec);
if (!baseRun || !compareRun) {
const unresolved = [!baseRun ? baseSpec : undefined, !compareRun ? compareSpec : undefined]
.filter((s): s is string => s !== undefined)
.map(s => JSON.stringify(s)).join(', ');
const runs = listRuns();
const available = runs.length > 0 ? runs.map(r => ` ${r.runId}`).join('\n') : ' (none)';
console.error(`friction diff failed: ${unresolved} matched no run-id or agent\navailable runs under ${frictionDir()}:\n${available}`);
return 1;
}
try {
const diff = computeFrictionDiff(baseRun, compareRun);
process.stdout.write((flags.bool('--json') ? JSON.stringify(diff, null, 2) : renderDiff(diff)) + '\n');
return 0;
} catch (e) {
console.error(`friction diff failed: ${e instanceof Error ? e.message : String(e)}`);
return 1;
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
@@ -174,12 +489,16 @@ Subcommands:
render Render a run's entries as markdown (default) or JSON
list List recent runs with friction/delight counts
summary Two-column summary of friction + delight for a run
diff Compare two runs: unique-to-each + shared-but-changed entries
Examples:
gbrain friction log --severity confused --phase install --message "init didn't say which engine"
gbrain friction render --run-id claw-test-20260428-... --transcripts
gbrain friction list --json
gbrain friction summary
gbrain friction diff --base openclaw --compare hermes --json
Run-id resolution: --run-id > $GBRAIN_FRICTION_RUN_ID > 'standalone'.`);
Run-id resolution: --run-id > $GBRAIN_FRICTION_RUN_ID > 'standalone'.
Diff run resolution: an exact run-id wins; otherwise the value is treated as
an agent name and resolves to that agent's latest run.`);
}
+321 -34
View File
@@ -67,6 +67,15 @@ import {
} from '../core/transcripts/claude-code-jsonl.ts';
import { CLAUDE_HOOK_OUTPUT_CAP_CHARS } from '../core/bootstrap/host-specs.ts';
import { readManifest, readReceipt, type InstallReceipt } from '../core/bootstrap/format.ts';
import { githubOwnerRepoString } from '../core/repo-visibility.ts';
import { detectExecutionEnvironment } from '../core/execution-env.ts';
import {
readPushStatuses,
readPushStatusForRoot,
sanitizePushReason,
summarizePushStatuses,
workspaceRootHash,
} from '../core/workspace-push.ts';
import { realpathOrResolve } from '../core/path-confine.ts';
// ── Tunables ────────────────────────────────────────────────────────────────
@@ -112,6 +121,17 @@ const USER_PROMPT_WINDOW_TURNS = 4;
export const PRIOR_CONTEXT_MAX_BYTES = 32 * 1024;
/** user-prompt transcript parse budget (tail bytes — the window only needs the newest turns). */
const USER_PROMPT_TRANSCRIPT_MAX_BYTES = 2 * 1024 * 1024;
/** stop-hook push [D3]: hard budget for the debounce decision + detached spawn
* (the spawn itself is instant; the budget bounds the two 1s git probes). */
const STOP_PUSH_DEADLINE_MS = 3000;
/** stop-hook push debounce default (minutes) for local + ephemeral-container
* environments; cloud-sandbox defaults to 0 (every turn) a reclaimed VM's
* tail loss is permanent, everywhere else SessionStart recovery covers it [D17]. */
export const STOP_PUSH_DEBOUNCE_MIN_DEFAULT = 5;
/** failure banner [D19]: re-announce floor while the same failure persists. */
export const PUSH_ANNOUNCE_REFIRE_MS = 30 * 60 * 1000;
/** failure banner budget (well under ENG-1's whole-payload cap). */
const PUSH_BANNER_MAX_CHARS = 300;
// ── Test seam ───────────────────────────────────────────────────────────────
@@ -191,6 +211,51 @@ export async function runHook(args: string[], io: HookIo = {}): Promise<number>
// for silence, and a disabled hook writing telemetry would be a lie).
if (process.env.GBRAIN_HOOKS === '0') return 0;
// #4043 harness-lane defer guard: Claude Code MERGES user- and
// project-scope hook settings, so a machine wired by `bootstrap harness`
// (user scope) plus a real workspace bootstrap install (settings.local.json,
// bootstrap-v1 marker) would fire the same event twice. The workspace
// install wins; the harness lane yields silently (exit 0, no output, no
// heartbeat). Same cwd resolution as the handlers (io.cwd is the test
// seam; the harness runs hooks in the session's working dir). Fail-open:
// any read hiccup means run normally.
if (process.env.GBRAIN_HOOK_LANE === 'harness') {
try {
// BOTH workspace carriers count: settings.local.json (local installs)
// and the committed .claude/settings.json ([D12] — an event owned by
// the committed carrier is stripped from local, so checking only local
// would double-fire it against the user-scope harness wiring). The
// check PARSES the settings and requires a live bootstrap-v1 hook entry
// wiring THIS event — a raw substring match would let any repo disable
// the machine-wide capture lane by committing the two marker strings in
// an unrelated field (ship-review P1), and would over-yield events the
// workspace does not actually wire.
const eventKey = {
'session-start': 'SessionStart',
'user-prompt': 'UserPromptSubmit',
stop: 'Stop',
'session-end': 'SessionEnd',
compact: 'PreCompact',
}[event];
const dotClaude = join(io.cwd ?? process.cwd(), '.claude');
for (const file of ['settings.local.json', 'settings.json']) {
const p = join(dotClaude, file);
if (!existsSync(p)) continue;
const settings = JSON.parse(readFileSync(p, 'utf8')) as {
hooks?: Record<string, Array<{ hooks?: Array<Record<string, unknown>> }>>;
};
const groups = settings.hooks?.[eventKey ?? ''];
if (!Array.isArray(groups)) continue;
for (const g of groups) {
if (!Array.isArray(g?.hooks)) continue;
if (g.hooks.some((e) => e?._gbrain === 'bootstrap-v1')) return 0;
}
}
} catch {
/* fail-open */
}
}
switch (event) {
case 'session-start':
return hookSessionStart(io);
@@ -591,16 +656,19 @@ async function lastSessionLine(): Promise<string | null> {
async function pushStatusNote(): Promise<string | null> {
try {
const home = await resolveHome();
const p = join(home, 'bootstrap', 'push-status.json');
if (!existsSync(p)) return null;
const s = JSON.parse(readFileSync(p, 'utf8')) as { ts?: string; ok?: boolean; reason?: string };
if (s.ok === false) {
return `Workspace push is FAILING (since ${s.ts ?? 'unknown'}): ${s.reason ?? 'unknown reason'} — run gbrain doctor`;
// One reader + one aggregation for every status surface [D8]; per-root
// files [D13] so one workspace's success can't mask another's failure.
const entries = readPushStatuses();
if (entries.length === 0) return null;
const { failing, stalestTs } = summarizePushStatuses(entries);
if (failing.length > 0) {
const e = failing[0]!;
const which = e.repoRoot ? ` for ${e.repoRoot}` : '';
const rest = failing.length > 1 ? ` [+${failing.length - 1} more workspace(s)]` : '';
return `Workspace push${which} is FAILING (since ${e.ts ?? 'unknown'}): ${sanitizePushReason(e.reason)}${rest} — run gbrain doctor`;
}
const t = s.ts ? Date.parse(s.ts) : NaN;
if (Number.isFinite(t) && Date.now() - t > PUSH_STALE_MS) {
return `Workspace push: last success ${s.ts} (>48h ago) — recent work may be unpushed [B4]`;
if (stalestTs !== null && Date.now() - stalestTs > PUSH_STALE_MS) {
return `Workspace push: last success ${new Date(stalestTs).toISOString()} (>48h ago) — recent work may be unpushed [B4]`;
}
return null;
} catch {
@@ -677,13 +745,10 @@ async function resolveBootstrapWorkspaceRoot(ws: string): Promise<string | null>
return root;
}
/** owner/name from a github https/ssh remote URL, or null. Local mirror of
* repo.ts's parser (kept here so the engine-free hook doesn't import repo.ts). */
/** owner/name from a github https/ssh remote URL, or null. Canonical parser
* (repo-visibility.ts is engine-free, so the hook contract holds). */
function githubOwnerName(url: string): string | null {
const m =
/^https:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/.exec(url.trim()) ??
/^git@github\.com:([^/]+)\/([^/]+?)(?:\.git)?$/.exec(url.trim());
return m ? `${m[1]}/${m[2]}` : null;
return githubOwnerRepoString(url);
}
/**
@@ -706,14 +771,22 @@ async function repoPhaseComplete(root: string): Promise<boolean> {
if (!receipt || typeof receipt.repo_url !== 'string' || receipt.repo_url.length === 0) return false;
if (realpathOrResolve(receipt.workspace_dir) !== realpathOrResolve(root)) return false;
const want = githubOwnerName(receipt.repo_url);
if (!want) return false;
const fetchUrl = await tryExecAsync('git', ['-C', root, 'remote', 'get-url', 'origin']);
if (githubOwnerName(fetchUrl ?? '') !== want) return false;
// Push URL (remote.origin.pushurl) via the config key directly (no dash-flag):
// unset → `git push` uses the fetch URL (already matched). Only a configured
// push URL that points elsewhere blocks the push.
const pushUrl = await tryExecAsync('git', ['-C', root, 'config', 'remote.origin.pushurl']);
return !pushUrl || githubOwnerName(pushUrl) === want;
if (want) {
if (githubOwnerName(fetchUrl ?? '') !== want) return false;
return !pushUrl || githubOwnerName(pushUrl) === want;
}
// Non-github repo_url (self-hosted / explicitly-trusted transports): bind
// by EXACT URL equality — the recorded url is what the repo phase (or the
// operator) verified, and a later remote redirect must still block the
// push. Without this branch, every non-github install's no-daemon push
// deferred forever (post-#4024 regression).
if ((fetchUrl ?? '').trim() !== receipt.repo_url) return false;
return !pushUrl || pushUrl.trim() === receipt.repo_url;
} catch {
return false;
}
@@ -749,13 +822,7 @@ async function dirtyTreePush(
try {
const root = await resolveBootstrapWorkspaceRoot(ws);
if (!root) return null;
const [status, aheadRaw] = await Promise.all([
tryExecAsync('git', ['-C', root, 'status', '--porcelain']),
tryExecAsync('git', ['-C', root, 'rev-list', '--count', '@{u}..HEAD']),
]);
const dirty = (status ?? '') !== '';
const ahead = aheadRaw !== null ? parseInt(aheadRaw, 10) || 0 : 0;
if (!dirty && ahead === 0) return null; // clean + up to date → nothing to recover
if (!(await treeNeedsPush(root))) return null; // clean + up to date → nothing to recover
// There IS unpushed work. Defer until the repo phase verified privacy +
// recorded repo_url — never recover-push to an unverified origin
// (create-repo-first race). Only fires when work actually exists (P2-1).
@@ -783,6 +850,181 @@ async function dirtyTreePush(
}
}
/** True when the workspace has uncommitted changes or commits ahead of
* upstream shared by the SessionStart recovery push and the stop-hook
* per-turn push. Two 1s-capped git probes; never throws. */
async function treeNeedsPush(root: string): Promise<boolean> {
// Dirty tree → always needs a push. For "ahead", measure against the SAME
// ref workspacePush targets (origin/<default-branch>), NOT @{u}: a branch
// with no upstream makes `@{u}..HEAD` error → 0, which would report a clean
// + committed-but-unpushed tree as push_clean and silently strand it (the
// exact tail-loss the per-turn push exists to prevent). When the origin ref
// doesn't resolve yet (never pushed), any commit past the empty tree counts
// as needs-push.
const status = await tryExecAsync('git', ['-C', root, 'status', '--porcelain']);
if ((status ?? '') !== '') return true;
const branch = await tryExecAsync('git', ['-C', root, 'branch', '--show-current']);
const b = (branch ?? '').trim();
if (b) {
const ahead = await tryExecAsync('git', ['-C', root, 'rev-list', '--count', `origin/${b}..HEAD`]);
if (ahead !== null) return (parseInt(ahead, 10) || 0) > 0;
// origin/<b> doesn't exist (never pushed) → any local commit needs pushing.
const have = await tryExecAsync('git', ['-C', root, 'rev-list', '--count', 'HEAD']);
return (parseInt(have ?? '0', 10) || 0) > 0;
}
// Detached HEAD / no branch name — fall back to the upstream measure.
const ahead = await tryExecAsync('git', ['-C', root, 'rev-list', '--count', '@{u}..HEAD']);
return ahead !== null && (parseInt(ahead, 10) || 0) > 0;
}
// ── stop-hook per-turn push [D3/D17/D20] ────────────────────────────────────
//
// SessionEnd never fires on /exit (upstream: closed not-planned), can't fire
// on crash, and a cloud sandbox VM may simply be reclaimed between turns —
// so the Stop boundary (fires after EVERY assistant turn) is the only cadence
// that always runs while work exists. Debounced per workspace root, detached
// spawn (instant), fail-open everywhere.
function stopPushStatePath(root: string): string {
return join(resolveGbrainHome(), 'bootstrap', `stop-push-${workspaceRootHash(root)}.json`);
}
/** Debounce resolution: env GBRAIN_STOP_PUSH_DEBOUNCE_MIN (minutes; 0 = every
* turn) file-plane config hooks.stop_push_debounce_min environment-kind
* default (cloud-sandbox: 0, everything else: 5). */
function stopPushDebounceMs(): number {
const env = process.env.GBRAIN_STOP_PUSH_DEBOUNCE_MIN;
if (env !== undefined) {
const n = Number.parseInt(env, 10);
if (Number.isFinite(n) && n >= 0) return n * 60_000;
}
try {
const cfg = loadConfig();
const v = cfg?.hooks?.stop_push_debounce_min;
const n = typeof v === 'number' ? v : typeof v === 'string' ? Number.parseInt(v, 10) : NaN;
if (Number.isFinite(n) && n >= 0) return n * 60_000;
} catch {
/* tolerant read — fall through to the default */
}
return detectExecutionEnvironment() === 'cloud-sandbox' ? 0 : STOP_PUSH_DEBOUNCE_MIN_DEFAULT * 60_000;
}
/** Floor for the [D20] failing-status retry cadence: a stuck push (e.g. gh
* unauthenticated for a day) must not re-run the full network ladder on every
* single turn one retry a minute keeps recovery fast without the storm. */
export const STOP_PUSH_FAILING_RETRY_FLOOR_MS = 60_000;
/** Decide + (maybe) spawn the per-turn push. Returns the heartbeat reason.
* Ordered cheapest-first: the debounce (two file reads) answers the common
* case before any git subprocess runs repoPhaseComplete's git probes only
* execute on turns that might actually spawn a push. */
async function stopPushIfDue(ws: string, io: HookIo): Promise<string> {
if (process.env.GBRAIN_STOP_PUSH === '0') return 'push_disabled';
const root = await resolveBootstrapWorkspaceRoot(ws);
if (!root) return 'push_skipped_not_bootstrap';
const stateP = stopPushStatePath(root);
let lastTs: number | null = null;
try {
const s = JSON.parse(readFileSync(stateP, 'utf8')) as { ts?: string };
const t = Date.parse(s.ts ?? '');
if (Number.isFinite(t)) lastTs = t;
} catch {
/* missing/corrupt state → due (fail-open) */
}
// [D20] a failing push bypasses the normal debounce so recovery is fast —
// but with a 60s floor so a persistently failing push can't re-run the
// network verification ladder on every turn (the push lock bounds
// concurrency, not cadence; the banner is already showing the failure).
const failing = readPushStatusForRoot(root)?.ok === false;
const now = Date.now();
// Healthy: the normal debounce (0 = every turn in cloud). Failing: a fixed
// 60s retry floor — faster than a long local debounce so a transient failure
// recovers within a turn or two, but NEVER every-turn (a Math.min against the
// cloud debounce of 0 was a re-run-the-ladder-every-turn storm; adversarial
// review caught it).
const windowMs = failing ? STOP_PUSH_FAILING_RETRY_FLOOR_MS : stopPushDebounceMs();
if (lastTs !== null && now - lastTs < windowMs) return 'push_debounced';
// Same privacy gate as SessionEnd: never push before the repo phase has
// verified the origin and recorded repo_url (create-repo-first race).
if (!(await repoPhaseComplete(root))) return 'push_deferred_repo_pending';
if (!(await treeNeedsPush(root))) return 'push_clean';
try {
// Written BEFORE the spawn so repeated fail-fast children stay debounced
// on the healthy path; the [D20] failing-status bypass handles retries.
mkdirSync(join(resolveGbrainHome(), 'bootstrap'), { recursive: true, mode: 0o700 });
const tmp = `${stateP}.tmp-${process.pid}`;
writeFileSync(tmp, JSON.stringify({ ts: new Date(now).toISOString(), root }) + '\n', { mode: 0o600 });
renameSync(tmp, stateP);
} catch {
/* state-write failure must not block the push itself */
}
try {
(io.spawnPush ?? spawnDetachedPush)(root);
return 'push_spawned';
} catch {
return 'push_unavailable';
}
}
// ── push-failure banner [D5/D13/D19] ────────────────────────────────────────
interface PushAnnounceState {
announced_ts?: string;
last_announce_at?: string;
}
/**
* The pending 300-char failure banner, or null. `record()` marks the due
* failures announced and is called ONLY after the banner actually reached
* stdout a deadline-suppressed banner must re-fire next turn. Announce
* state is a sidecar next to each per-root status file (`<file>.announced`):
* each new failure `ts` announces once, then re-announces at most every
* PUSH_ANNOUNCE_REFIRE_MS while the failure persists [D19].
*/
function pendingPushFailureBanner(): { text: string; record: () => void } | null {
try {
const failing = readPushStatuses().filter((e) => e.ok === false);
if (failing.length === 0) return null;
const now = Date.now();
const due = failing.filter((e) => {
try {
const s = JSON.parse(readFileSync(`${e.file}.announced`, 'utf8')) as PushAnnounceState;
if (s.announced_ts !== e.ts) return true;
const last = Date.parse(s.last_announce_at ?? '');
return !Number.isFinite(last) || now - last > PUSH_ANNOUNCE_REFIRE_MS;
} catch {
return true; // never announced (or unreadable state) → due
}
});
if (due.length === 0) return null;
const first = due[0]!;
const which = first.repoRoot ?? 'the workspace';
const more = due.length > 1 ? ` (+${due.length - 1} more workspace(s))` : '';
const text = (
`NOTICE: the background workspace push for ${which} is FAILING ` +
`(${sanitizePushReason(first.reason)})${more} — work is committed locally ` +
'but NOT on GitHub. Run gbrain doctor.'
).slice(0, PUSH_BANNER_MAX_CHARS);
const record = () => {
for (const e of due) {
try {
writeFileSync(
`${e.file}.announced`,
JSON.stringify({ announced_ts: e.ts, last_announce_at: new Date(now).toISOString() }) + '\n',
{ mode: 0o600 },
);
} catch {
/* fail-open — worst case the banner re-fires */
}
}
};
return { text, record };
} catch {
return null;
}
}
// ── user-prompt [ENG-1, S3#8, A9] ───────────────────────────────────────────
interface UserPromptOutcome {
@@ -798,7 +1040,18 @@ async function hookUserPrompt(io: HookIo): Promise<number> {
if (!expired) write(io, s);
};
// [D5/D19] Same-session failure surfacing: a refused/failed background push
// becomes visible on the NEXT turn — to the model via additionalContext AND
// to the human via systemMessage ("never silent" must not depend on the
// model choosing to relay its own tooling's failure). Embedded in the main
// payload when one is written; emitted alone on every degraded path.
// Computed INSIDE the deadline-raced closure: its sync file reads must be
// budgeted by the 800ms deadline, not free-ride before the race starts.
let banner: ReturnType<typeof pendingPushFailureBanner> = null;
let wrotePayload = false;
const work = (async (): Promise<UserPromptOutcome> => {
banner = pendingPushFailureBanner();
const j = await readStdinJson(io, 300);
if (!j) return { outcome: 'degraded', reason: 'no_stdin' };
@@ -890,19 +1143,27 @@ async function hookUserPrompt(io: HookIo): Promise<number> {
// [ENG-1] The 10000-char harness cap applies to the WHOLE stdout payload;
// the block is budgeted ≤8KB server-side, but JSON escaping inflates, so
// trim defensively rather than letting the harness divert-and-drop.
// trim defensively rather than letting the harness divert-and-drop. The
// banner (≤300 chars, fixed) rides inside the same payload [D5] — only
// blockText is trimmed, so the failure notice survives the cap loop.
const bannerPrefix = banner ? `${banner.text}\n\n` : '';
const buildPayload = (block: string) =>
JSON.stringify({
hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: bannerPrefix + block },
...(banner ? { systemMessage: banner.text } : {}),
});
let blockText = text;
let payload = JSON.stringify({
hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: blockText },
});
let payload = buildPayload(blockText);
while (payload.length > CLAUDE_HOOK_OUTPUT_CAP_CHARS && blockText.length > 0) {
blockText = blockText.slice(0, Math.max(0, blockText.length - (payload.length - CLAUDE_HOOK_OUTPUT_CAP_CHARS) - 16));
payload = JSON.stringify({
hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: blockText },
});
payload = buildPayload(blockText);
}
if (blockText.length === 0) return { outcome: 'degraded', reason: 'over_cap', turns: turns.length };
guardedWrite(payload + '\n');
if (!expired) {
wrotePayload = true;
banner?.record();
}
// Partial trim is delivery-count drift: the serve already logged the FULL
// post-budget set at the response write, but pages cut from the tail here
// were never injected. Record it so the doctor's heartbeat reconciliation
@@ -927,6 +1188,21 @@ async function hookUserPrompt(io: HookIo): Promise<number> {
expired = true;
result = { outcome: 'error', reason: errorCode(e) };
}
// Banner-only emission [D5]: every path that did NOT write the main payload
// (no_serve, ipc_unavailable, no_pglite_path, empty windows, transcript
// aborts, …) still surfaces the push failure — unless the deadline expired,
// in which case record() was never called and the banner re-fires next turn.
// (Local copy: TS cannot track the closure-side assignment of `banner`.)
const pendingBanner = banner as { text: string; record: () => void } | null;
if (pendingBanner && !wrotePayload && !expired) {
guardedWrite(
JSON.stringify({
hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: pendingBanner.text },
systemMessage: pendingBanner.text,
}) + '\n',
);
pendingBanner.record();
}
await writeHeartbeat({
ts: new Date().toISOString(),
event: 'user-prompt',
@@ -1031,8 +1307,9 @@ async function hookStop(io: HookIo): Promise<number> {
const t0 = Date.now();
let outcome: HookHeartbeatEntry['outcome'] = 'ok';
let reason: string | undefined;
let j: Record<string, unknown> | null = null;
try {
const j = await readStdinJson(io, 300);
j = await readStdinJson(io, 300);
const sessionId = sanitizeSessionId(j?.session_id);
const dir = await liveBufferDir();
const exchange = firstString(j, ['last_assistant_message', 'lastAssistantMessage', 'prompt']);
@@ -1047,11 +1324,21 @@ async function hookStop(io: HookIo): Promise<number> {
outcome = 'error';
reason = errorCode(e);
}
// Per-turn durability push [D3/D17/D20] — its own try/deadline so the
// buffer append above and the heartbeat below are never at risk.
let pushReason: string | undefined;
try {
const ws = io.cwd ?? (typeof j?.cwd === 'string' ? (j.cwd as string) : process.cwd());
const raced = await withDeadline(STOP_PUSH_DEADLINE_MS, stopPushIfDue(ws, io));
pushReason = raced === DEADLINE ? 'push_unavailable' : raced;
} catch {
pushReason = 'push_unavailable';
}
await writeHeartbeat({
ts: new Date().toISOString(),
event: 'stop',
outcome,
...(reason ? { reason } : {}),
...((reason ?? pushReason) ? { reason: reason ?? pushReason } : {}),
duration_ms: Date.now() - t0,
});
return 0;
+85 -17
View File
@@ -25,6 +25,34 @@ import {
resumeFilter,
} from '../core/import-checkpoint.ts';
/**
* Records one failed file against the run's error-grouping state and
* returns the running count for its group plus an unredacted sample
* message for display.
*
* `key` groups structurally-identical errors (e.g. the same failure
* across many files) so a single noisy failure mode doesn't produce
* thousands of near-duplicate warning lines quoted substrings (typically
* a per-file slug or path) are blanked for the GROUPING key only. The
* printed `sample` is always a real, unredacted occurrence of the error
* (the first one seen for that key), so identifying details that are
* constant across the whole group a Postgres table or constraint name,
* for instance survive into what actually gets shown to the user.
* Pre-fix, the redacted key itself was printed, so e.g. a `pages_source_id_fkey`
* foreign-key violation surfaced as `table "" violates foreign key constraint ""`.
*/
export function recordImportFailure(
errorCounts: Record<string, number>,
errorSamples: Record<string, string>,
msg: string,
): { key: string; count: number; sample: string } {
const key = msg.replace(/"[^"]*"/g, '""');
const count = (errorCounts[key] ?? 0) + 1;
errorCounts[key] = count;
if (!(key in errorSamples)) errorSamples[key] = msg;
return { key, count, sample: errorSamples[key] };
}
function defaultWorkers(): number {
const cpuCount = cpus().length;
const memGB = totalmem() / (1024 ** 3);
@@ -288,7 +316,13 @@ export async function runImport(
let chunksCreated = 0;
const importedSlugs: string[] = [];
const errorCounts: Record<string, number> = {};
const errorSamples: Record<string, string> = {};
const failures: Array<{ path: string; error: string }> = []; // Bug 9
// #3839: paths that succeeded (imported OR unchanged) this run, keyed the
// same way as `failures` above (importRelPath) so a path that failed on a
// prior run and now succeeds clears its ledger row instead of staying
// `open` forever.
const succeededPaths: string[] = [];
const startTime = Date.now();
// Progress on stderr so stdout stays clean for the final summary / --json payload.
@@ -328,6 +362,7 @@ export async function runImport(
importedSlugs.push(result.slug);
// v0.33.2: path-based checkpoint — record only on success.
completed.add(relativePath);
succeededPaths.push(importRelPath); // #3839
} else {
skipped++;
if (result.error && result.error !== 'unchanged') {
@@ -340,16 +375,16 @@ export async function runImport(
// 'unchanged' or no-error skip: content_hash matched a prior
// successful import, so this file IS done for checkpoint purposes.
completed.add(relativePath);
succeededPaths.push(importRelPath); // #3839
}
}
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
const errorKey = msg.replace(/"[^"]*"/g, '""');
errorCounts[errorKey] = (errorCounts[errorKey] || 0) + 1;
if (errorCounts[errorKey] <= 5) {
const { count, sample } = recordImportFailure(errorCounts, errorSamples, msg);
if (count <= 5) {
console.error(` Warning: skipped ${relativePath}: ${msg}`);
} else if (errorCounts[errorKey] === 6) {
console.error(` (suppressing further "${errorKey.slice(0, 60)}..." errors)`);
} else if (count === 6) {
console.error(` (suppressing further "${sample.slice(0, 60)}..." errors)`);
}
errors++;
skipped++;
@@ -450,9 +485,9 @@ export async function runImport(
progress.finish();
// Error summary
for (const [err, count] of Object.entries(errorCounts)) {
for (const [key, count] of Object.entries(errorCounts)) {
if (count > 5) {
console.error(` ${count} files failed: ${err.slice(0, 100)}`);
console.error(` ${count} files failed: ${errorSamples[key].slice(0, 100)}`);
}
}
@@ -545,8 +580,9 @@ export async function runImport(
// Import → sync continuity: write sync checkpoint if this is a git repo.
// Bug 9 — gate last_commit on "no failures" so import doesn't silently
// stomp on the sync bookmark when parsing broke. We still write
// last_run + repo_path either way (those are progress indicators).
// stomp on the sync bookmark when parsing broke. last_run + repo_path are
// written alongside it, but ONLY when this import owns the globals (#2114
// guard below) — a foreign directory must not repoint the brain repo.
let gitHead: string | null = null;
try {
if (existsSync(join(dir, '.git'))) {
@@ -568,17 +604,49 @@ export async function runImport(
const { recordFailures } = await import('../core/sync.ts');
recordFailures(opts.sourceId ?? 'default', failures, gitHead);
}
if (failures.length === 0) {
await engine.setConfig('sync.last_commit', gitHead);
} else {
// #3839: a path that failed on a prior run and succeeded (imported or
// unchanged) this run must clear its ledger row — pre-fix, clearFailures
// existed but had no caller anywhere, so `open` rows never healed short
// of a manual `gbrain sync --skip-failed`. Runs on every non-empty
// success list regardless of whether this SAME run also had failures,
// so a stale row from an earlier run gets cleared even if today's run
// is only partially clean.
if (succeededPaths.length > 0) {
const { clearFailures } = await import('../core/sync.ts');
clearFailures(opts.sourceId ?? 'default', succeededPaths);
}
// #2114 guard: the global sync.* keys describe THE brain repo (the
// default source's working tree). Pre-fix this block rewrote them on
// every git-repo import, silently repointing put_page write-through
// and poisoning the incremental sync anchor. Ownership + the bootstrap
// rule live in ownsGlobalSyncAnchor (shared with writeSyncAnchor's
// legacy branch in sync.ts, so the two layers cannot drift).
const { ownsGlobalSyncAnchor } = await import('../core/sync.ts');
const { owns, configured } = await ownsGlobalSyncAnchor(engine, sourceId, dir);
if (owns) {
if (failures.length === 0) {
await engine.setConfig('sync.last_commit', gitHead);
} else {
console.error(
`\nImport completed with ${failures.length} failure(s). ` +
`sync.last_commit NOT advanced — re-run 'gbrain sync' to retry, or ` +
`'gbrain sync --skip-failed' to acknowledge and move past them.`,
);
}
await engine.setConfig('sync.last_run', new Date().toISOString());
await engine.setConfig('sync.repo_path', dir);
} else if ((sourceId ?? 'default') === 'default') {
console.error(
`\nImport completed with ${failures.length} failure(s). ` +
`sync.last_commit NOT advanced — re-run 'gbrain sync' to retry, or ` +
`'gbrain sync --skip-failed' to acknowledge and move past them.`,
`\n[import] sync.repo_path stays at ${configured ?? '(unset)'} — NOT repointing to "${dir}". ` +
`Sync bookmarks were not advanced. If this directory IS your brain repo, run: ` +
`gbrain config set sync.repo_path "${dir}"`,
);
}
await engine.setConfig('sync.last_run', new Date().toISOString());
await engine.setConfig('sync.repo_path', dir);
// Non-default sources: deliberately silent no-op — the globals are not
// this import's to move (its sync anchors live on the `sources` row).
}
return { imported, skipped, errors, chunksCreated, failures };
+31 -19
View File
@@ -5,15 +5,17 @@
* config writes work [CDX-7]. Idempotent: if `search.mode` is already set
* (re-init / second run), the picker is skipped entirely.
*
* TTY flow shows the menu. Non-TTY (CI, scripted init, --mcp-only) writes
* `balanced` and prints the one-line hint pointing at `gbrain config set
* search.mode`. The mode picker NEVER blocks an init run — readLineSafe
* caps at 60s and falls back to `balanced` on timeout / EOF.
* TTY flow shows the menu. Non-TTY (CI, scripted init, --mcp-only) applies
* the auto-recommendation, prints the cost matrix + an [AGENT] directive to
* confirm with the operator, and points at `gbrain config set search.mode`.
* The mode picker NEVER blocks an init run readLineSafe caps at 60s and
* falls back to the recommendation on timeout / EOF.
*
* Smart auto-suggestion: reads models.tier.subagent / models.default /
* OPENAI_API_KEY presence + brain size hint to RECOMMEND a mode. The
* recommendation is informational only the user picks. This is the
* "agents perfectly tune for user needs" piece at install time.
* expansion-capable key presence (Anthropic/OpenAI/Google) + brain size hint
* to RECOMMEND a mode. The recommendation is informational only the user
* picks. This is the "agents perfectly tune for user needs" piece at
* install time.
*/
import type { BrainEngine } from '../core/engine.ts';
@@ -36,8 +38,11 @@ export interface ModePickerInputs {
subagentModel?: string | null;
/** Configured default model id. */
defaultModel?: string | null;
/** True iff an OpenAI API key is configured. */
hasOpenAIKey?: boolean;
/** True iff an expansion-capable API key (Anthropic / OpenAI / Google) is
* configured. LLM query expansion routes through the gateway's chat lane,
* not the embedding lane an OpenAI-only gate wrongly told Anthropic-keyed
* installs "no LLM expansion possible". */
hasExpansionKey?: boolean;
/** Approximate page count of the brain (after initSchema, before bulk import). */
pageCount?: number;
}
@@ -50,8 +55,8 @@ export interface ModePickerInputs {
* shape per the v0.32.3 install-picker directive):
* - Opus / Frontier model OR Sonnet / unknown tokenmax (max-quality default)
* - Haiku subagent conservative (cost-sensitive setups)
* - No OpenAI key configured conservative (LLM expansion not possible
* anyway, so tight budget makes more sense)
* - No expansion-capable key (Anthropic/OpenAI/Google) conservative
* (LLM expansion cannot run anyway, so tight budget makes more sense)
*
* Rationale: the previous "default to balanced unless Opus detected" logic
* silently downgraded users who were running Sonnet-tier work and expected
@@ -67,10 +72,10 @@ export function recommendModeFor(inputs: ModePickerInputs): { mode: SearchMode;
reason: 'Haiku subagent tier detected — tight 4K budget keeps per-call cost down.',
};
}
if (inputs.hasOpenAIKey === false) {
if (inputs.hasExpansionKey === false) {
return {
mode: 'conservative',
reason: 'No OpenAI key configured — semantic cache still works, but no LLM expansion possible.',
reason: 'No expansion-capable API key (Anthropic/OpenAI/Google) — semantic cache still works, but LLM query expansion cannot run.',
};
}
const opus = /opus/i.test(inputs.defaultModel ?? '') || /opus/i.test(inputs.subagentModel ?? '');
@@ -109,7 +114,12 @@ async function resolveInputs(engine: BrainEngine): Promise<ModePickerInputs> {
return {
subagentModel,
defaultModel,
hasOpenAIKey: Boolean(process.env.OPENAI_API_KEY),
hasExpansionKey: Boolean(
process.env.ANTHROPIC_API_KEY ||
process.env.OPENAI_API_KEY ||
process.env.GOOGLE_GENERATIVE_AI_API_KEY ||
process.env.GEMINI_API_KEY, // gateway accepts GEMINI_API_KEY as a first-class alias
),
pageCount,
};
}
@@ -214,7 +224,9 @@ export async function runModePicker(
// 2 stderr lines that agents typically miss / don't relay to the human.
// Now: the agent sees the same matrix the human-TTY picker shows, plus
// a directive saying "show this to your operator and confirm before
// moving on." Default-applied mode is tokenmax (preserves v0.31.x shape).
// moving on." The applied default is the recommendation above — the
// banner interpolates it (a static "preserves the v0.31.x shape" line
// used to claim tokenmax semantics even when conservative was applied).
if (!process.stdin.isTTY) {
try { await engine.setConfig(SEARCH_MODE_KEY, rec.mode); } catch { /* swallow */ }
console.log('');
@@ -224,10 +236,10 @@ export async function runModePicker(
console.log('═══════════════════════════════════════════════════════════════');
console.log('');
console.log('[AGENT] Before continuing, SHOW this matrix to your operator and');
console.log('[AGENT] ask which mode they want. The default above preserves the');
console.log('[AGENT] v0.31.x retrieval shape. Run `gbrain config set search.mode');
console.log('[AGENT] <conservative|balanced|tokenmax>` if the operator picks');
console.log('[AGENT] differently. See INSTALL_FOR_AGENTS.md Step 3.5 for the');
console.log(`[AGENT] ask which mode they want. The default above (${rec.mode}) was`);
console.log('[AGENT] auto-selected from the Reason line. Run `gbrain config set');
console.log('[AGENT] search.mode <conservative|balanced|tokenmax>` if the operator');
console.log('[AGENT] picks differently. See INSTALL_FOR_AGENTS.md Step 3.5 for the');
console.log('[AGENT] full protocol.');
console.log('');
console.log('Per-query cost @ 10K queries/mo (search payload only, no cache savings):');
+82 -16
View File
@@ -8,13 +8,22 @@
* picker's UI and `gbrain providers list` can't drift.
*
* Trust contract:
* - TTY-only. Callers must not invoke this in non-TTY contexts; D3 says
* non-TTY with zero keys exits 1 from `resolveAIOptions` before we
* reach here. A defensive guard returns null if no TTY anyway.
* - Filters candidates to env-ready recipes (codex finding #3). The
* picker is for choosing among providers the user CAN run, not for
* walking them through key setup.
* - On Ctrl-D / EOF / timeout: returns null, caller treats as exit 1.
* - TTY-only. Callers must not invoke this in non-TTY contexts (non-TTY
* zero-key resolves keyless in `resolveEmbeddingByEnv` before reaching
* here). A defensive guard returns null if no TTY anyway.
* - Filters candidates to env-ready recipes (codex finding #3), and
* probe-gates LOCAL daemons (ollama): daemon-up model-pulled, so an
* unreachable daemon is dropped and a missing model is annotated with
* its `ollama pull` fix inline.
* - Embedding pickers always offer `0) none — continue keyless`. When no
* KEYED provider is ready, keyless is the DEFAULT (bare Enter / 60s
* timeout / EOF all resolve to 0 null), so a local daemon is never
* auto-selected. When a keyed provider IS ready the default is `1`, so an
* unattended timeout picks that first keyed provider NOT null; explicit
* `0` is still keyless.
* - Returns null on the keyless choice (and on invalid input); the embedding
* caller continues keyless with a loud notice on BOTH the zero-key and the
* multi-key paths (other touchpoints treat null as no-pick).
* - When the user picks a non-Anthropic chat-capable recipe AND
* `ANTHROPIC_API_KEY` is missing, prints the subagent caveat from D7
* BEFORE returning the choice so the user sees the implication.
@@ -23,6 +32,7 @@
import { listRecipes } from '../core/ai/recipes/index.ts';
import { envReady, formatRecipeTable } from './providers.ts';
import { readLineSafe } from './init.ts';
import { probeOllama, type ProbeResult } from '../core/ai/probes.ts';
import type { Recipe } from '../core/ai/types.ts';
export interface PickedProvider {
@@ -46,6 +56,10 @@ export interface PickProviderOpts {
isTTY?: boolean;
/** Stderr override for tests (capturing prompts). Defaults to process.stderr.write. */
writeStderr?: (s: string) => void;
/** Local-daemon probe seam (injected for tests; defaults to probeOllama).
* Keeps the unit suite off the network and off any REAL ollama that
* happens to be running on the test machine. */
probeLocal?: () => Promise<ProbeResult>;
}
/**
@@ -107,19 +121,57 @@ export async function pickProvider(opts: PickProviderOpts): Promise<PickedProvid
}
const all = listRecipes();
const ready = readyRecipesForTouchpoint(all, opts.touchpoint, env);
let ready = readyRecipesForTouchpoint(all, opts.touchpoint, env);
if (ready.length === 0) {
// Probe-gate the ollama daemon: `envReady` treats no-key-required as
// ready, but daemon-up ≠ model-pulled — the exact trap that let a keyless
// Enter "choose" a broken ollama config and continue silently degraded.
// Drop ollama when its daemon doesn't answer; annotate it when the daemon
// answers but hasn't pulled the recipe's model. Scoped to ollama — other
// local recipes (claude-cli) have no daemon to probe.
const localHints = new Map<string, string>();
const localRecipes = ready.filter((r) => r.id === 'ollama');
if (localRecipes.length > 0) {
const probe = opts.probeLocal ?? probeOllama;
let probeResult: ProbeResult;
try {
probeResult = await probe();
} catch {
probeResult = { reachable: false };
}
if (!probeResult.models_endpoint_valid) {
ready = ready.filter((r) => r.id !== 'ollama');
} else {
for (const r of localRecipes) {
const tp = r.touchpoints[opts.touchpoint];
const wanted = tp && 'models' in tp && Array.isArray(tp.models) ? tp.models[0] : undefined;
const served = probeResult.models ?? [];
if (wanted && !served.some((m) => m === wanted || m.startsWith(`${wanted}:`))) {
localHints.set(r.id, `model not pulled — run: ollama pull ${wanted}`);
}
}
}
}
// Keyless is always a valid embedding choice — the brain works with
// keyword search + agent-authored memory. Offer it explicitly instead of
// forcing a keypress through a provider menu.
const keylessOption = opts.touchpoint === 'embedding';
if (ready.length === 0 && !keylessOption) {
writeStderr(`\nNo ${opts.touchpoint}-capable providers are env-ready.\n`);
writeStderr('Set one of the env vars below and re-run init:\n\n');
writeStderr(formatRecipeTable(all, env) + '\n\n');
return null;
}
writeStderr(`\nPick a ${opts.touchpoint} provider (env-ready providers shown):\n\n`);
writeStderr(formatRecipeTable(ready, env) + '\n\n');
// Article-aware: touchpoint is 'embedding' | 'expansion' | 'chat' — a
// hardcoded article renders "an chat provider".
const article = /^[aeiou]/i.test(opts.touchpoint) ? 'an' : 'a';
writeStderr(`\nPick ${article} ${opts.touchpoint} provider (env-ready providers shown):\n\n`);
if (ready.length > 0) writeStderr(formatRecipeTable(ready, env) + '\n\n');
// Build numbered options
// Build numbered options (0 = keyless skip for embedding).
const lines = ready.map((r, i) => {
const tp = r.touchpoints[opts.touchpoint];
let label = ` ${i + 1}) ${r.id}`;
@@ -129,21 +181,35 @@ export async function pickProvider(opts: PickProviderOpts): Promise<PickedProvid
if (tp && 'models' in tp && Array.isArray(tp.models) && tp.models.length > 0) {
label += ` ${tp.models[0]}`;
}
const hint = localHints.get(r.id);
if (hint) label += ` [${hint}]`;
return label;
});
if (keylessOption) {
lines.unshift(' 0) none — continue keyless (keyword search; add a key later)');
}
writeStderr(lines.join('\n') + '\n\n');
// Default: keyless when no remote (keyed) provider is ready — a bare Enter
// must never select a local daemon the user didn't ask for.
const hasKeyedReady = ready.some((r) => (r.auth_env?.required ?? []).length > 0);
const defaultChoice = keylessOption && !hasKeyedReady ? '0' : '1';
const low = keylessOption ? 0 : 1;
const answer = await readLineSafe(
`Choice [1-${ready.length}, default 1]: `,
'1',
`Choice [${low}-${ready.length}, default ${defaultChoice}]: `,
defaultChoice,
/* timeoutMs */ 60_000,
);
const choice = parseInt(answer.trim(), 10);
if (!Number.isFinite(choice) || choice < 1 || choice > ready.length) {
writeStderr(`\nInvalid choice "${answer}". Aborting.\n`);
if (!Number.isFinite(choice) || choice < low || choice > ready.length) {
writeStderr(`\nInvalid choice "${answer}".\n`);
return null;
}
if (keylessOption && choice === 0) {
return null; // caller continues keyless with its own notice
}
const picked = ready[choice - 1];
const tp = picked.touchpoints[opts.touchpoint];
+126 -51
View File
@@ -491,9 +491,16 @@ export async function findEnvKeyTypos(
return out;
}
/** Emit the fail-loud "no embedding provider" message + paste-ready setup. */
/** Emit the "no embedding provider" message + paste-ready setup. Keyless
* continue leads (it always works); key setup follows for the upgrade. */
function printNoEmbeddingProviderHint(typos: Array<{ userSet: string; suggested: string }>): void {
console.error('\nNo embedding provider configured. Set one of:');
console.error('\nNo embedding provider configured.');
console.error('Continue without one (keyless — keyword search + memory your agent writes):');
console.error(' gbrain init --pglite --no-embedding');
console.error(' (enable semantic search later by re-running with a key:');
console.error(' gbrain init --force --pglite --embedding-model <id>)');
console.error('');
console.error('Or set a key for semantic search:');
console.error(' export OPENAI_API_KEY=sk-… # openai:text-embedding-3-large (1536d)');
console.error(' export ZEROENTROPY_API_KEY=ze-… # zeroentropyai:zembed-1 (2560d, Matryoshka)');
console.error(' export VOYAGE_API_KEY=pa-… # voyage:voyage-3-large (1024d)');
@@ -501,9 +508,6 @@ function printNoEmbeddingProviderHint(typos: Array<{ userSet: string; suggested:
console.error('');
console.error('Or pick explicitly:');
console.error(' gbrain init --pglite --embedding-model openai:text-embedding-3-large');
console.error('');
console.error('Or defer setup: gbrain init --pglite --no-embedding');
console.error(' (you can configure later with `gbrain config set embedding_model <id>`)');
// D13: surface near-miss env vars (e.g. OPENAPI_API_KEY → OPENAI_API_KEY).
if (typos.length > 0) {
console.error('');
@@ -513,6 +517,19 @@ function printNoEmbeddingProviderHint(typos: Array<{ userSet: string; suggested:
}
}
/** Loud keyless-continue notice for the no-keys default path. The upgrade
* command is `init --force` re-init, NOT `config set embedding_model` that
* key is a schema-sizing file-plane field that `gbrain config set` refuses
* (it would be a silent no-op), so pointing users there is a dead end. */
function printKeylessContinueNotice(): void {
console.error(
'No embedding provider keys detected — continuing in keyless mode:\n' +
' keyword search + memory your agent writes down itself. Everything works.\n' +
' One optional key upgrades search to semantic — set the key, then re-run\n' +
' `gbrain init --force --pglite --embedding-model <id>` (re-imports via `gbrain sync`).',
);
}
async function resolveEmbeddingByEnv(out: ResolvedAIOptions, nonInteractive: boolean): Promise<void> {
const ready = await groupReadyByProvider('embedding');
const isTTY = !nonInteractive && !!process.stdin.isTTY;
@@ -548,28 +565,56 @@ async function resolveEmbeddingByEnv(out: ResolvedAIOptions, nonInteractive: boo
}
}
// Zero or multi — pick or fail loud.
// Zero keys — keyless is a first-class posture (the whole paste-in
// bootstrap runs on it), so the DEFAULT is to continue keyless with a loud
// notice, not exit 1. Fail-loud survives in exactly one zero-key case: a
// near-miss env var (OPENAPI_API_KEY → OPENAI_API_KEY) signals the user
// MEANT to configure a key — completing keyless there would silently bury
// their typo.
if (ready.length === 0) {
if (!isTTY) {
const typos = await findEnvKeyTypos();
const typos = await findEnvKeyTypos();
if (typos.length > 0) {
printNoEmbeddingProviderHint(typos);
process.exit(1);
}
// TTY → picker; on null (user aborted) still fail loud.
if (!isTTY) {
printKeylessContinueNotice();
out.noEmbedding = true;
return;
}
// TTY → picker (local providers like ollama may be selectable); a null
// pick (nothing offered, user skipped, or EOF) continues keyless.
const { pickProvider } = await import('./init-provider-picker.ts');
const picked = await pickProvider({ touchpoint: 'embedding', env: process.env, isTTY: true });
if (!picked) {
const typos = await findEnvKeyTypos();
printNoEmbeddingProviderHint(typos);
process.exit(1);
printKeylessContinueNotice();
out.noEmbedding = true;
return;
}
out.embedding_model = picked.fullModel;
out.embedding_dimensions = picked.dim;
return;
}
// ready.length > 1 — picker (TTY) or fail-loud (non-TTY) per D2/D3.
// ready.length > 1 — picker (TTY); non-TTY auto-picks the canonical default
// when its key is present (the most common agent/dev setup is 2+ provider
// keys — failing there blocked scripted installs), else fail-loud per D2/D3
// (a genuinely ambiguous set with no canonical candidate stays explicit).
if (!isTTY) {
const { DEFAULT_EMBEDDING_MODEL, DEFAULT_EMBEDDING_DIMENSIONS } =
await import('../core/ai/defaults.ts');
const canonicalProvider = DEFAULT_EMBEDDING_MODEL.split(':')[0];
const canonical = ready.find((p) => p.recipeId === canonicalProvider);
if (canonical) {
out.embedding_model = DEFAULT_EMBEDDING_MODEL;
out.embedding_dimensions = DEFAULT_EMBEDDING_DIMENSIONS;
console.error(
`Multiple embedding providers env-ready (${ready.map(p => p.recipeId).join(', ')}). ` +
`Using the default ${DEFAULT_EMBEDDING_MODEL} (${DEFAULT_EMBEDDING_DIMENSIONS}d). ` +
`Override with --embedding-model.`,
);
return;
}
console.error(`Multiple embedding providers env-ready: ${ready.map(p => p.recipeId).join(', ')}.`);
console.error(`Disambiguate by passing --embedding-model <provider>:<model>, or unset extra env vars.`);
process.exit(1);
@@ -577,8 +622,13 @@ async function resolveEmbeddingByEnv(out: ResolvedAIOptions, nonInteractive: boo
const { pickProvider } = await import('./init-provider-picker.ts');
const picked = await pickProvider({ touchpoint: 'embedding', env: process.env, isTTY: true });
if (!picked) {
console.error('Init aborted: no embedding provider picked.');
process.exit(1);
// The embedding picker offers an explicit "0) none — continue keyless"
// option (and returns null on it). Honor that instead of aborting: a user
// with multiple keys who deliberately chose keyless gets keyless, matching
// the zero-key path. (Ctrl-D / EOF / invalid also land here → keyless.)
printKeylessContinueNotice();
out.noEmbedding = true;
return;
}
out.embedding_model = picked.fullModel;
out.embedding_dimensions = picked.dim;
@@ -1080,18 +1130,16 @@ async function initPGLite(opts: {
} else {
console.log(`\nBrain ready at ${dbPath}`);
console.log(`${stats.page_count} pages. Engine: PGLite (local Postgres).`);
// Reference/status blocks print FIRST and terse; the ONE primary action
// (the memory demo) prints LAST so it is the final, unmistakable thing on
// screen. Krug: one obvious next action, everything else subordinate.
if (stats.page_count > 0) {
console.log('');
console.log('Existing brain detected. To wire up the v0.10.3 knowledge graph:');
console.log('Existing brain detected. Wire up the knowledge graph:');
console.log(' gbrain extract links --source db (typed link backfill)');
console.log(' gbrain extract timeline --source db (structured timeline backfill)');
console.log(' gbrain stats (verify links > 0)');
} else {
console.log('Next: gbrain import <dir>');
}
printMemoryVerbsQuickstart();
console.log('');
console.log('When you outgrow local: gbrain migrate --to supabase');
reportModStatus();
const { printAdvisoryIfRecommended } = await import('../core/skillpack/post-install-advisory.ts');
const { VERSION } = await import('../version.ts');
@@ -1101,6 +1149,9 @@ async function initPGLite(opts: {
// Fail-open; 3s wallclock cap. Skipped silently in non-TTY contexts.
const { runInitNudge } = await import('../core/onboard/init-nudge.ts');
await runInitNudge(engine);
// The single primary action, last-on-screen.
printMemoryVerbsQuickstart({ emptyBrain: stats.page_count === 0, onPglite: true });
}
} finally {
try { await engine.disconnect(); } catch { /* best-effort */ }
@@ -1108,23 +1159,33 @@ async function initPGLite(opts: {
}
/**
* MEMORY_VERBS v1 quickstart funnel (E3 + D4B + T1 consent). Printed at the
* end of both init epilogues. The copy-next block is EXACTLY three commands
* (codex DX 9): wire the harness, write a memory, prove the resurrection.
* The demo uses the facts arm only, so it works with NO embedding key [F-B].
* MEMORY_VERBS v1 quickstart funnel (E3 + D4B + T1 consent). Printed LAST in
* both init epilogues as the ONE primary action. The copy-next block is
* EXACTLY three commands (codex DX 9): wire the harness, write a memory, prove
* the resurrection. The demo uses the facts arm only, so it works with NO
* embedding key [F-B]. Secondary paths (import, migrate) ride a single terse
* "More:" footer so they never compete with the primary action.
*/
function printMemoryVerbsQuickstart(): void {
function printMemoryVerbsQuickstart(opts: { emptyBrain?: boolean; onPglite?: boolean } = {}): void {
console.log('');
console.log('Give your agent memory (copy these three commands):');
console.log('→ Do this next — give your agent memory (copy these three commands):');
console.log(' claude mcp add gbrain -- gbrain serve --surface verbs');
console.log(' gbrain remember "I prefer dark mode in every editor" --provenance demo --entity people/me');
console.log(' gbrain recall --entity people/me');
console.log('Now ask your agent in a NEW session — it remembers.');
console.log('Then ask your agent in a NEW session — it remembers.');
console.log('');
console.log('Note: memories agents save are readable by every agent connected to');
console.log('this brain; use visibility:"private" for local-only facts.');
console.log('Other harnesses (Codex, OpenClaw): docs/protocol/MEMORY_VERBS_v1.md');
console.log('If `claude` is not found: install Claude Code first, or use the per-harness blocks in that doc.');
// Secondary paths, one line, clearly subordinate to the action above.
console.log('');
console.log(
'More: ' +
(opts.emptyBrain ? 'bulk-load notes `gbrain import <dir>` · ' : '') +
(opts.onPglite ? 'scale up `gbrain migrate --to supabase` · ' : '') +
'health `gbrain doctor`',
);
}
async function initPostgres(opts: {
@@ -1350,14 +1411,11 @@ async function initPostgres(opts: {
console.log(`\nBrain ready. ${stats.page_count} pages. Engine: Postgres (Supabase).`);
if (stats.page_count > 0) {
console.log('');
console.log('Existing brain detected. To wire up the v0.10.3 knowledge graph:');
console.log('Existing brain detected. Wire up the knowledge graph:');
console.log(' gbrain extract links --source db (typed link backfill)');
console.log(' gbrain extract timeline --source db (structured timeline backfill)');
console.log(' gbrain stats (verify links > 0)');
} else {
console.log('Next: gbrain import <dir>');
}
printMemoryVerbsQuickstart();
reportModStatus();
const { printAdvisoryIfRecommended } = await import('../core/skillpack/post-install-advisory.ts');
const { VERSION } = await import('../version.ts');
@@ -1367,6 +1425,9 @@ async function initPostgres(opts: {
// Fail-open; 3s wallclock cap. Skipped silently in non-TTY contexts.
const { runInitNudge } = await import('../core/onboard/init-nudge.ts');
await runInitNudge(engine);
// The single primary action, last-on-screen.
printMemoryVerbsQuickstart({ emptyBrain: stats.page_count === 0 });
}
} finally {
try { await engine.disconnect(); } catch { /* best-effort */ }
@@ -1402,6 +1463,15 @@ function countMarkdownFiles(dir: string, maxScan = 1500): number {
}
async function supabaseWizard(): Promise<string> {
// Non-TTY guard: without a terminal the URL prompt below can never be
// answered — the legacy behavior was a silent exit-0 no-op (stdin closed →
// readLine never resolved data → process ended with NO config written), the
// worst failure shape for a scripted/agent caller. Fail loud with the fix.
if (!process.stdin.isTTY) {
console.error('gbrain init --supabase needs an interactive terminal to prompt for the connection URL.');
console.error('Non-interactive: pass --url <connection_string>, or set GBRAIN_DATABASE_URL and use --non-interactive.');
process.exit(1);
}
try {
execSync('bunx supabase --version', { stdio: 'pipe' });
console.log('Supabase CLI detected.');
@@ -1427,12 +1497,21 @@ function readLine(prompt: string): Promise<string> {
return new Promise((resolve) => {
process.stdout.write(prompt);
let data = '';
let settled = false;
const settle = (value: string) => {
if (settled) return;
settled = true;
process.stdin.pause();
resolve(value);
};
process.stdin.setEncoding('utf-8');
process.stdin.once('data', (chunk) => {
data = chunk.toString().trim();
process.stdin.pause();
resolve(data);
settle(data);
});
// EOF (Ctrl-D mid-prompt) resolves empty instead of hanging — the caller's
// "No URL provided." guard then fails loud.
process.stdin.once('end', () => settle(''));
process.stdin.resume();
});
}
@@ -1578,23 +1657,18 @@ export function reportModStatus(): void {
skillCount = manifest.skills?.length || 0;
} catch { /* manifest not found */ }
// One line per fact, one pointer per optional extra — this block sits on
// the init success screen, where every extra call-to-action competes with
// the memory-verbs funnel (the one action that matters). Krug: one screen,
// one primary action.
console.log('');
console.log('--- GBrain Mod Status ---');
console.log(`Skills: ${skillCount} loaded`);
console.log(`GStack: ${gstack.found ? `found (${gstack.host})` : 'not found'}`);
if (!gstack.found) {
console.log(' Install GStack for coding skills:');
console.log(' git clone https://github.com/garrytan/gstack.git ~/.claude/skills/gstack');
console.log(' cd ~/.claude/skills/gstack && ./setup');
}
console.log('Resolver: skills/RESOLVER.md');
console.log('Soul audit: ask your agent to "run a soul audit" to customize its identity (see skills/soul-audit)');
console.log(`Skills: ${skillCount} loaded (router: skills/RESOLVER.md)`);
console.log(`GStack: ${gstack.found ? `found (${gstack.host})` : 'not found (coding skills — see github.com/garrytan/gstack)'}`);
// Retrieval Reflex (#1981): the deterministic pointer layer is ON by default
// (no action needed). The policy skill is installed into the HOST repo on
// request — we PRINT the command rather than silently mutating the host repo.
console.log('Retrieval reflex: on by default (entity pointers injected per turn)');
console.log(' Install the policy skill into your agent repo:');
console.log(' gbrain integrations install retrieval-reflex --target <host-repo>');
// request — we PRINT the pointer rather than silently mutating the host repo.
console.log('Retrieval reflex: on by default. More: `gbrain integrations` (policy skill), skills/soul-audit (identity).');
console.log('');
}
@@ -1606,7 +1680,7 @@ USAGE
gbrain init [flags]
ENGINE SELECTION (mutually exclusive)
--pglite Use embedded PGLite (zero-config, default for <1000 .md files)
--pglite Use embedded PGLite (zero-config, the default)
--supabase Use Supabase Postgres (recommended for 1000+ files)
--url <URL> Use a manual Postgres connection string
--mcp-only Thin-client mode: connect to a remote gbrain MCP, no local engine
@@ -1639,9 +1713,10 @@ EXAMPLES
gbrain init --mcp-only --url https://... # Thin-client mode
NOTES
- Bare \`gbrain init\` in a directory with 1000+ .md files defaults to Supabase
interactive setup. With <1000 files (or with --pglite explicitly), defaults
to PGLite at ~/.gbrain/brain.pglite.
- Bare \`gbrain init\` always defaults to PGLite at ~/.gbrain/brain.pglite.
In a directory with 1000+ .md files it prints a suggestion to use
\`gbrain init --supabase\` (faster search at scale) but still proceeds
with PGLite.
- Existing config is preserved unless --force is passed.
`.trim());
}
+38 -1
View File
@@ -1708,7 +1708,44 @@ export async function registerBuiltinHandlers(
});
worker.register('extract', async (job) => {
const { runExtractCore } = await import('./extract.ts');
const { runExtractCore, extractStaleFromDB, STALE_TIME_BUDGET_MS } = await import('./extract.ts');
// #2849: stale mode — the durable follow-up for extraction deferred by
// performSync's size gate (totalChanges > 100). Runs the same DB-source
// watermark sweep as `gbrain extract --stale`, scoped to the source the
// sync that deferred it was scoped to (job.data.sourceId; absent =
// unscoped, matching what the CLI hint tells a default-brain operator
// to run). The sweep is checkout-less + idempotent, so retries and
// overlapping submissions converge.
if (job.data.stale === true) {
const sourceIdFilter = typeof job.data.sourceId === 'string' ? job.data.sourceId : undefined;
const r = await extractStaleFromDB(engine, {
dryRun: !!job.data.dryRun,
jsonMode: false,
includeFrontmatter: false,
sourceIdFilter,
catchUp: false,
});
// Internal 30-min budget hit with work remaining → chain a
// continuation job so a very large deferred backlog converges without
// waiting for the next sync. Forward-progress guard (pagesProcessed >
// 0) prevents an infinite chain if the sweep can't advance.
if (!job.data.dryRun && r.staleRemaining > 0 && r.pagesProcessed > 0) {
try {
const queue = new MinionQueue(engine);
// NO maxWaiting: with an unscoped (NULL-sourceId) payload the
// coalesce filter matches ANY waiting 'extract' job and would
// swallow the continuation. Each completed sweep chains at most
// one continuation and the sweep is an idempotent watermark scan,
// so there is no pile-up to guard against.
await queue.add(
'extract',
{ ...job.data, continuation_of: job.id },
{ timeout_ms: STALE_TIME_BUDGET_MS + 5 * 60 * 1000 },
);
} catch { /* best-effort: next sync/manual sweep picks up the rest */ }
}
return { stale: true, source_id: sourceIdFilter ?? null, ...r };
}
const mode = (typeof job.data.mode === 'string' && ['links', 'timeline', 'all'].includes(job.data.mode))
? (job.data.mode as 'links' | 'timeline' | 'all')
: 'all';
+14 -1
View File
@@ -389,7 +389,20 @@ export async function runMigrateEmbeddings(
exit(0);
} else {
if (flags.json) {
console.log(JSON.stringify({ status: 'incomplete', plan, embedded: embedResult.embedded, remaining }, null, 2));
console.log(JSON.stringify({
status: 'incomplete', plan, embedded: embedResult.embedded, remaining,
...(embedResult.lock_skipped && { lock_skipped: true }),
}, null, 2));
} else if (embedResult.lock_skipped) {
// E2E-observed failure mode: a hard-killed (SIGKILL/crash) migration
// leaves its single-flight embed lock behind, and every immediate
// re-run "resumes" without embedding anything. Say so — "re-run to
// resume" would be a lie until the lock expires.
const { EMBED_BACKFILL_LOCK_TTL_MIN } = await import('../core/embed-backfill-lock.ts');
serr(`Migration paused: ${remaining} chunk(s) still stale, and the re-embed was SKIPPED because`);
serr('another embed backfill holds the per-source lock. If that is a live run (check');
serr('`gbrain jobs list`), let it finish. If a previous migration was killed hard, its lock');
serr(`expires after at most ${EMBED_BACKFILL_LOCK_TTL_MIN} minutes — re-run the same command then.`);
} else {
serr(`Migration incomplete: ${remaining} chunk(s) still stale (embed failures or an interrupted run).`);
serr('Re-run the same command to resume — completed chunks are never re-embedded.');
+24 -13
View File
@@ -80,6 +80,10 @@ export function manifestMatchesTarget(manifest: MigrateManifest, targetId: strin
return manifest.schema_version === 2 && manifest.target_id === targetId;
}
function makeManifestKey(sourceId: string, slug: string): string {
return sourceId === 'default' ? slug : `${sourceId}::${slug}`;
}
function loadManifest(): MigrateManifest | null {
const path = getManifestPath();
if (!existsSync(path)) return null;
@@ -151,6 +155,25 @@ export async function copyMigrationSources(source: BrainEngine, target: BrainEng
}
}
export async function copyPageLinksToTarget(
source: BrainEngine,
target: BrainEngine,
page: Page,
failedKeys: ReadonlySet<string> = new Set(),
): Promise<void> {
const links = await source.getLinks(page.slug, { sourceId: page.source_id });
for (const link of links) {
const toSourceId = link.to_source_id ?? page.source_id;
if (failedKeys.has(makeManifestKey(toSourceId, link.to_slug))) continue;
await target.addLink(
link.from_slug, link.to_slug,
link.context, link.link_type,
undefined, undefined, undefined,
{ fromSourceId: page.source_id, toSourceId },
);
}
}
/**
* postgres.js's UNDEFINED_VALUE guard rejects any bound parameter that is JS
* `undefined` unlike PGLite, it will not silently treat it as SQL NULL.
@@ -564,8 +587,6 @@ export async function runMigrateEngine(sourceEngine: BrainEngine, args: string[]
// entries were bare slugs; we keep treating those as default-source for
// back-compat resume.
const completedSet = new Set(manifest?.completed_slugs || []);
const makeManifestKey = (sourceId: string, slug: string): string =>
sourceId === 'default' ? slug : `${sourceId}::${slug}`;
if (!manifest) {
manifest = {
completed_slugs: [],
@@ -680,17 +701,7 @@ export async function runMigrateEngine(sourceEngine: BrainEngine, args: string[]
progress.tick(1);
continue;
}
const sourceOpts = { sourceId: page.source_id };
const links = await sourceEngine.getLinks(page.slug, sourceOpts);
for (const link of links) {
if (failedKeys.has(makeManifestKey(page.source_id, link.to_slug))) continue;
await targetEngine.addLink(
link.from_slug, link.to_slug,
link.context, link.link_type,
undefined, undefined, undefined,
{ fromSourceId: page.source_id, toSourceId: page.source_id },
);
}
await copyPageLinksToTarget(sourceEngine, targetEngine, page, failedKeys);
progress.tick(1);
}
progress.finish();
+43 -83
View File
@@ -34,7 +34,8 @@ import {
resolveModel,
type ModelTier,
} from '../core/model-config.ts';
import { resolveRecipe } from '../core/ai/model-resolver.ts';
import { maybeAttachVersionSuffixHint } from '../core/ai/base-url-probe.ts';
import type { AIGatewayConfig } from '../core/ai/types.ts';
const TIERS: ModelTier[] = ['utility', 'reasoning', 'deep', 'subagent'];
@@ -223,42 +224,33 @@ function classifyError(err: unknown): { status: ProbeStatus; message: string } {
return { status: 'unknown', message: msg };
}
const OPENAI_COMPAT_V1_HINT =
'If the API key is correct, the base URL may be missing the /v1 suffix. ' +
'OpenAI-shaped proxies (codex-proxy, Azure-OpenAI mirrors, LiteLLM fronting an OpenAI route) ' +
'serve /v1/chat/completions and 401 on the bare path. ' +
'Confirm with: `curl <base>/models` returns 200 with the same bearer, then append /v1 to the base URL.';
/** Injectable transport + per-run hint cache for the doctor probes. */
interface ProbeDeps {
chat?: typeof import('../core/ai/gateway.ts').chat;
embed?: typeof import('../core/ai/gateway.ts').embed;
rerank?: typeof import('../core/ai/gateway.ts').rerank;
cfg?: AIGatewayConfig;
fetchImpl?: typeof fetch;
cache?: Map<string, string | undefined>;
}
/**
* Fix-hint for the openai-compatible-proxy `/v1`-suffix trap.
*
* An OpenAI-shaped proxy whose base URL omits `/v1` (codex-proxy, some
* Azure-OpenAI mirrors, a LiteLLM proxy fronting an OpenAI-route backend)
* serves `/v1/chat/completions` and returns 401 on the bare `/chat/completions`
* the AI SDK appends to the base. `classifyError` reads that 401 as `auth` and
* points the operator at the bearer token, when the real fix is the URL shape.
*
* Returns the corrective hint only when the model routes through an
* openai-compatible recipe (proxy tier, not native anthropic/openai/google),
* `baseURL` is set, and `baseURL` does not already end in `/v1` (optionally with
* a trailing slash). Pure: recipe resolution is synchronous and does no
* network/engine work; any resolution failure returns undefined.
*
* @internal exported for tests.
* Build a failed-probe ProbeResult and attach the base-URL version hint. Shared
* by the three reachability-probe catch blocks. `resultTouchpoint` names the
* result row; `hintTouchpoint` is the auth-resolution touchpoint the hint uses.
*/
export function openAiCompatV1Hint(
async function failedProbe(
err: unknown,
modelStr: string,
baseURL: string | undefined | null,
): string | undefined {
if (!baseURL || !baseURL.trim()) return undefined;
if (/\/v1\/?$/.test(baseURL.trim())) return undefined;
try {
const { recipe } = resolveRecipe(modelStr);
if (recipe.tier !== 'openai-compat') return undefined;
return OPENAI_COMPAT_V1_HINT;
} catch {
return undefined;
}
resultTouchpoint: ProbeResult['touchpoint'],
hintTouchpoint: 'embedding' | 'expansion' | 'chat' | 'reranker',
start: number,
deps: ProbeDeps,
): Promise<ProbeResult> {
const { status, message } = classifyError(err);
const result: ProbeResult = { model: modelStr, touchpoint: resultTouchpoint, status, message, elapsed_ms: Date.now() - start };
await maybeAttachVersionSuffixHint(result, modelStr, hintTouchpoint, deps);
return result;
}
/**
@@ -487,7 +479,7 @@ async function probeRerankerConfig(engine: BrainEngine): Promise<ProbeResult> {
* when set so a CPU-only local reranker's cold-start warmup doesn't
* cause the probe to false-fail with `network`/timeout.
*/
async function probeRerankerReachability(engine: BrainEngine): Promise<ProbeResult | null> {
export async function probeRerankerReachability(engine: BrainEngine, deps: ProbeDeps = {}): Promise<ProbeResult | null> {
const modelStr = await resolveLiveRerankerModel(engine);
if (!modelStr) return null;
@@ -501,7 +493,7 @@ async function probeRerankerReachability(engine: BrainEngine): Promise<ProbeResu
const start = Date.now();
try {
const { rerank } = await import('../core/ai/gateway.ts');
const rerank = deps.rerank ?? (await import('../core/ai/gateway.ts')).rerank;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(new Error(`probe timed out after ${probeTimeoutMs}ms`)), probeTimeoutMs);
try {
@@ -523,14 +515,7 @@ async function probeRerankerReachability(engine: BrainEngine): Promise<ProbeResu
clearTimeout(timeoutId);
}
} catch (err) {
const { status, message } = classifyError(err);
return {
model: modelStr,
touchpoint: 'reranker_config',
status,
message,
elapsed_ms: Date.now() - start,
};
return failedProbe(err, modelStr, 'reranker_config', 'reranker', start, deps);
}
}
@@ -547,10 +532,11 @@ async function probeRerankerReachability(engine: BrainEngine): Promise<ProbeResu
* Cold-start note: a local CPU embedder loading a model on first call can take
* several seconds; the 5s timeout may trip on the very first probe. Re-run if so.
*/
async function probeEmbeddingReachability(): Promise<ProbeResult | null> {
const { getEmbeddingModel, embed } = await import('../core/ai/gateway.ts');
const modelStr = getEmbeddingModel();
export async function probeEmbeddingReachability(deps: ProbeDeps = {}): Promise<ProbeResult | null> {
const gw = await import('../core/ai/gateway.ts');
const modelStr = gw.getEmbeddingModel();
if (!modelStr) return null;
const embed = deps.embed ?? gw.embed;
const start = Date.now();
const controller = new AbortController();
@@ -565,23 +551,16 @@ async function probeEmbeddingReachability(): Promise<ProbeResult | null> {
elapsed_ms: Date.now() - start,
};
} catch (err) {
const { status, message } = classifyError(err);
return {
model: modelStr,
touchpoint: 'embedding_reachability',
status,
message,
elapsed_ms: Date.now() - start,
};
return failedProbe(err, modelStr, 'embedding_reachability', 'embedding', start, deps);
} finally {
clearTimeout(timeoutId);
}
}
async function probeModel(modelStr: string, touchpoint: 'chat' | 'expansion'): Promise<ProbeResult> {
export async function probeModel(modelStr: string, touchpoint: 'chat' | 'expansion', deps: ProbeDeps = {}): Promise<ProbeResult> {
const start = Date.now();
try {
const { chat } = await import('../core/ai/gateway.ts');
const chat = deps.chat ?? (await import('../core/ai/gateway.ts')).chat;
// Use AbortController so the 5s timeout doesn't hang on a stuck network.
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(new Error('probe timed out after 5s')), 5000);
@@ -597,30 +576,7 @@ async function probeModel(modelStr: string, touchpoint: 'chat' | 'expansion'): P
clearTimeout(timeoutId);
}
} catch (err) {
const { status, message } = classifyError(err);
const result: ProbeResult = { model: modelStr, touchpoint, status, message, elapsed_ms: Date.now() - start };
// An openai-compatible proxy whose base URL omits `/v1` returns 401 (not
// 404) on the bare `/chat/completions` path, which classifyError reads as
// `auth`. Attach the URL-shape hint so the operator doesn't chase the
// bearer token. Fail open: any error resolving the base URL yields no hint
// and never breaks the probe.
if (status === 'auth') {
try {
const { loadConfig } = await import('../core/config.ts');
const { buildGatewayConfig } = await import('../core/ai/build-gateway-config.ts');
const fileCfg = loadConfig();
if (fileCfg) {
const cfg = buildGatewayConfig(fileCfg);
const { recipe } = resolveRecipe(modelStr);
const baseURL = cfg.base_urls?.[recipe.id] ?? recipe.base_url_default;
const hint = openAiCompatV1Hint(modelStr, baseURL);
if (hint) result.fix = hint;
}
} catch {
// fail open — no hint
}
}
return result;
return failedProbe(err, modelStr, touchpoint, touchpoint, start, deps);
}
}
@@ -700,19 +656,23 @@ Tiers: utility (haiku-class) | reasoning (sonnet) | deep (opus) | subagent (Anth
// config keys live search reads (closes file-plane / DB-plane divergence).
results.push(await probeRerankerConfig(engine));
// Per-run cache so several probe sites sharing a base URL run the base-URL
// /models sweep once, not once per site.
const hintCache = new Map<string, string | undefined>();
for (const [modelStr, touchpoint] of [[chatModel, 'chat'], [expansionModel, 'expansion']] as const) {
if (shouldSkipProvider(modelStr, skip)) {
if (!json) process.stderr.write(`[skip] ${touchpoint}: ${modelStr} (provider in --skip)\n`);
continue;
}
results.push(await probeModel(modelStr, touchpoint));
results.push(await probeModel(modelStr, touchpoint, { cache: hintCache }));
}
// v0.40.x: embedding reachability — only when the config probe passed
// (codex #8: a config failure shouldn't be reported twice) AND the provider
// isn't in --skip. Catches a dead/misconfigured LOCAL embed server early.
if (embeddingConfig.status === 'ok' && !shouldSkipProvider(embeddingConfig.model, skip)) {
const er = await probeEmbeddingReachability();
const er = await probeEmbeddingReachability({ cache: hintCache });
if (er) results.push(er);
}
@@ -721,7 +681,7 @@ Tiers: utility (haiku-class) | reasoning (sonnet) | deep (opus) | subagent (Anth
// actually enabled per the resolved mode bundle.
const liveRerankerModel = await resolveLiveRerankerModel(engine);
if (liveRerankerModel && !shouldSkipProvider(liveRerankerModel, skip)) {
const r = await probeRerankerReachability(engine);
const r = await probeRerankerReachability(engine, { cache: hintCache });
if (r) results.push(r);
}
+12 -3
View File
@@ -38,7 +38,7 @@ import {
type SearchMode,
type ModeBundle,
} from '../core/search/mode.ts';
import { readSearchStats } from '../core/search/telemetry.ts';
import { readSearchStats, telemetryCoverage, TELEMETRY_COVERAGE_CAVEAT } from '../core/search/telemetry.ts';
const KNOB_DESCRIPTIONS: Record<keyof ModeBundle, string> = {
cache_enabled: 'Semantic query cache on/off',
@@ -225,6 +225,7 @@ async function runStatsSubcommand(engine: BrainEngine, args: string[]): Promise<
console.log(JSON.stringify({
schema_version: 2,
...stats,
coverage: telemetryCoverage(),
graph_signals: gsSection,
_meta: {
metric_glossary: {
@@ -241,11 +242,15 @@ async function runStatsSubcommand(engine: BrainEngine, args: string[]): Promise<
}
console.log(`Search stats over the last ${stats.window_days} days:`);
console.log(` Coverage note: ${TELEMETRY_COVERAGE_CAVEAT}`);
console.log('');
console.log(` Total searches: ${stats.total_calls}`);
if (stats.total_calls === 0) {
console.log('');
console.log('No telemetry recorded yet. Run a few `gbrain query` calls and re-check.');
console.log('No telemetry recorded in this window. This can mean no search activity, or');
console.log('it can reflect the coverage gap above — a lone short-lived CLI call is often');
console.log('not enough to trigger a flush. `gbrain serve` / an MCP session is more likely');
console.log('to record counts over time (telemetry stays best-effort either way).');
// Still print the graph-signals section since failures are tracked
// independently of the search_telemetry table.
if (gsSection.enabled || gsSection.failures_count > 0) {
@@ -382,6 +387,7 @@ async function runTuneSubcommand(engine: BrainEngine, args: string[]): Promise<v
schema_version: 2,
status: 'insufficient_data',
total_calls: stats.total_calls,
coverage: telemetryCoverage(),
recommendations: [],
message: 'Not enough search activity in the last 7 days to tune. Run `gbrain search stats` after some real usage.',
}, null, 2));
@@ -389,7 +395,8 @@ async function runTuneSubcommand(engine: BrainEngine, args: string[]): Promise<v
}
console.log('Not enough search activity in the last 7 days to tune.');
console.log(`Total searches: ${stats.total_calls} (need >= 20 for confident recommendations).`);
console.log('Run a few `gbrain query` calls, then re-run `gbrain search tune`.');
console.log(`(${TELEMETRY_COVERAGE_CAVEAT} Low counts can reflect this gap, not just low usage.)`);
console.log('Use `gbrain serve` or an MCP session for a while, then re-run `gbrain search tune`.');
return;
}
@@ -448,6 +455,7 @@ async function runTuneSubcommand(engine: BrainEngine, args: string[]): Promise<v
total_calls: stats.total_calls,
cache_hit_rate: stats.cache_hit_rate,
active_mode: resolved.resolved_mode,
coverage: telemetryCoverage(),
recommendations: recs,
applied: apply ? recs.map(r => r.apply_command) : [],
_meta: {
@@ -466,6 +474,7 @@ async function runTuneSubcommand(engine: BrainEngine, args: string[]): Promise<v
}
console.log(`Search tune (last 7 days, active mode: ${resolved.resolved_mode}):`);
console.log(`(${TELEMETRY_COVERAGE_CAVEAT})`);
console.log('');
if (recs.length === 0) {
+234 -47
View File
@@ -25,15 +25,31 @@ import { mcpAuthRouter } from '@modelcontextprotocol/sdk/server/auth/router.js';
import { requireBearerAuth } from '@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js';
import { OAuthTokenRevocationRequestSchema } from '@modelcontextprotocol/sdk/shared/auth.js';
import type { BrainEngine } from '../core/engine.ts';
import { operations, OperationError } from '../core/operations.ts';
import { operations, OperationError, opAllowedForBoundClient } from '../core/operations.ts';
import type { OperationContext, AuthInfo } from '../core/operations.ts';
import { GBrainOAuthProvider, validateTokenEndpointAuthMethod } from '../core/oauth-provider.ts';
import { disabledOpsForPublishGates } from '../mcp/publish-gates.ts';
import {
GBrainOAuthProvider,
validateTokenEndpointAuthMethod,
dcrRegistrationContext,
DEFAULT_DCR_TTL_MIN_SECONDS,
} from '../core/oauth-provider.ts';
import type { SqlQuery } from '../core/oauth-provider.ts';
import { hasScope, ALLOWED_SCOPES_LIST, normalizeScopesInput } from '../core/scope.ts';
import { normalizeTokenScopes } from '../core/legacy-token-scope.ts';
import { normalizeSourceInput, normalizeFederatedReadInput } from '../core/source-id.ts';
import { summarizeMcpParams, dispatchToolCall } from '../mcp/dispatch.ts';
import { paramDefToSchema } from '../mcp/tool-defs.ts';
import { filterOpsForSurface } from '../mcp/surface.ts';
import { summarizeMcpParams, dispatchToolCall, requestLogStatusForResult } from '../mcp/dispatch.ts';
import { resolveStrictParamsMode } from '../mcp/validate-params.ts';
import { buildToolDefs } from '../mcp/tool-defs.ts';
import {
filterOpsForSurface,
clampSurface,
minSurface,
resolveClientRowSurface,
resolveDefaultClientSurface,
type McpSurface,
} from '../mcp/surface.ts';
import { writeSurfaceChangeAudit } from '../core/surface-audit.ts';
import { getBrainHotMemoryMeta } from '../core/facts/meta-hook.ts';
import { loadConfig } from '../core/config.ts';
import { buildError, serializeError } from '../core/errors.ts';
@@ -477,11 +493,14 @@ interface ServeHttpOptions {
*/
suppressBootstrapToken?: boolean;
/**
* MEMORY_VERBS v1: tool-surface mode. 'verbs' = exactly the five protocol
* verbs; 'full' (default) = every non-localOnly operation. Enforced on the
* tool list AND in dispatch (fail-closed).
* MEMORY_VERBS v1 + WP4: tool-surface mode. 'verbs' = exactly the seven
* protocol verbs; 'starter' = the STARTER_OPS daily-driver set; 'full'
* (default) = every non-localOnly operation. Enforced on the tool list AND
* in dispatch (fail-closed). WP4/D2: this is the server CEILING each
* request resolves min(ceiling, client row surface ?? config default),
* so per-client rows can narrow below it but never widen past it.
*/
surface?: 'verbs' | 'full';
surface?: McpSurface;
/**
* #2624: force-print the generated admin bootstrap token even on a
* non-TTY (containerized) start. By default the raw token is only printed
@@ -702,11 +721,41 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption
// constructor option instead of monkey-patching `_clientsStore` after
// construction. Same outcome (no /register endpoint when --enable-dcr
// is not passed); cleaner shape for tests and future maintainers.
// #2179: admin-configured clamp window for DCR-requested token TTLs.
// DB-plane config keys (`gbrain config set oauth.dcr_ttl_min_seconds ...`).
// FAIL-CLOSED defaults: an unset/invalid max is bounded by the operator's
// own --token-ttl (never a fixed permissive ceiling), and an inverted
// window collapses to the min bound — the same direction clampDcrTokenTtl
// itself resolves. A bad config narrows the window; it never widens it.
const parseDcrTtlBound = (raw: unknown, fallback: number): number => {
const n = Number(raw);
return raw != null && Number.isFinite(n) && n >= 1 ? Math.floor(n) : fallback;
};
let dcrTtlMinSeconds = DEFAULT_DCR_TTL_MIN_SECONDS;
let dcrTtlMaxSeconds = Math.max(tokenTtl, dcrTtlMinSeconds);
try {
dcrTtlMinSeconds = parseDcrTtlBound(await engine.getConfig('oauth.dcr_ttl_min_seconds'), DEFAULT_DCR_TTL_MIN_SECONDS);
dcrTtlMaxSeconds = parseDcrTtlBound(await engine.getConfig('oauth.dcr_ttl_max_seconds'), Math.max(tokenTtl, dcrTtlMinSeconds));
} catch {
// Config read is best-effort; the fail-closed defaults stand.
dcrTtlMaxSeconds = Math.max(tokenTtl, dcrTtlMinSeconds);
}
if (dcrTtlMinSeconds > dcrTtlMaxSeconds) {
console.error(
`[serve-http] WARNING: oauth.dcr_ttl_min_seconds (${dcrTtlMinSeconds}) exceeds ` +
`oauth.dcr_ttl_max_seconds (${dcrTtlMaxSeconds}); collapsing the window to ` +
`the min bound (${dcrTtlMinSeconds}).`,
);
dcrTtlMaxSeconds = dcrTtlMinSeconds;
}
const oauthProvider = new GBrainOAuthProvider({
sql,
tokenTtl,
dcrDisabled: !enableDcr,
allowClientCredentialsDcr: enableDcrInsecure === true,
dcrTtlMinSeconds,
dcrTtlMaxSeconds,
});
// #1353: loud stderr security WARN when DCR is enabled. DCR is an
@@ -820,6 +869,20 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption
app.use('/register', cors(corsOAuthOptions));
app.use('/revoke', cors(corsOAuthOptions));
// #2179: capture the optional `token_ttl_seconds` DCR extension field
// BEFORE the SDK's /register handler runs — its request schema strips
// unknown body members, so the value would never reach registerClient.
// The rest of the chain runs inside dcrRegistrationContext; the clients
// store clamps + persists it. Malformed values are ignored (fail-safe:
// absent → server default; out-of-range → clamped downstream; a TTL hint
// never rejects a registration). express.json() here is idempotent with
// the SDK router's own body parser.
app.use('/register', express.json(), (req: Request, _res: Response, next: NextFunction) => {
const raw = (req.body as Record<string, unknown> | null | undefined)?.token_ttl_seconds;
const tokenTtlSeconds = typeof raw === 'number' && Number.isFinite(raw) ? raw : undefined;
dcrRegistrationContext.run({ tokenTtlSeconds }, next);
});
// ---------------------------------------------------------------------------
// Custom client_credentials handler (before mcpAuthRouter)
// SDK's token handler only supports authorization_code and refresh_token
@@ -1303,7 +1366,9 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption
`;
const legacyKeys = await sql`
SELECT a.id, a.name, 'api_key' as auth_type,
'{"bearer"}' as grant_types, 'read write admin' as scope, a.created_at, null as token_ttl,
'{"bearer"}' as grant_types,
a.scopes,
a.created_at, null as token_ttl,
CASE WHEN a.revoked_at IS NOT NULL THEN 'revoked' ELSE 'active' END as status,
a.last_used_at,
(SELECT count(*)::int FROM mcp_request_log WHERE token_name = a.name) as total_requests,
@@ -1312,7 +1377,15 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption
`;
res.json([
...oauthClients,
...legacyKeys.map((key) => ({ ...key, source_id: null, federated_read: [] })),
...legacyKeys.map(({ scopes, ...key }) => ({
...key,
// The SAME normalizer the verify path uses — the dashboard must
// never display a grant the serve doesn't enforce (NULL =
// grandfathered full access; damaged/deny rows show empty).
scope: normalizeTokenScopes(scopes)?.join(' ') ?? 'read write admin',
source_id: null,
federated_read: [],
})),
]);
} catch (e) {
res.status(503).json({ error: 'service_unavailable' });
@@ -1366,8 +1439,13 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption
try {
const now = Math.floor(Date.now() / 1000);
const [expiring] = await sql`SELECT count(*)::int as count FROM oauth_tokens WHERE token_type = 'access' AND expires_at BETWEEN ${now} AND ${now + 86400}`;
const [errors] = await sql`SELECT count(*)::int as count FROM mcp_request_log WHERE status != 'success' AND created_at > now() - interval '24 hours'`;
const [total] = await sql`SELECT count(*)::int as count FROM mcp_request_log WHERE created_at > now() - interval '24 hours'`;
// Excluded from the error numerator: success and success_with_warnings
// (a warn-mode success); denied_after_list stays counted — a denied
// call IS a failure signal. surface_change is an OPERATION value (audit
// rows carry status='success'), so audit rows are excluded from BOTH
// counts — they are records of operator/self actions, not traffic.
const [errors] = await sql`SELECT count(*)::int as count FROM mcp_request_log WHERE status NOT IN ('success', 'success_with_warnings') AND operation != 'surface_change' AND created_at > now() - interval '24 hours'`;
const [total] = await sql`SELECT count(*)::int as count FROM mcp_request_log WHERE operation != 'surface_change' AND created_at > now() - interval '24 hours'`;
const errorRate = (total as any).count > 0 ? ((errors as any).count / (total as any).count * 100).toFixed(1) : '0';
res.json({
expiring_soon: (expiring as any).count,
@@ -1744,7 +1822,7 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption
// validator inside rescopeClient.
app.post('/admin/api/rescope-client', requireAdmin, express.json(), async (req: Request, res: Response) => {
try {
const { clientId, sourceId, federatedRead, boundSlugPrefixes } = req.body ?? {};
const { clientId, sourceId, federatedRead, boundSlugPrefixes, surface } = req.body ?? {};
if (!clientId || typeof clientId !== 'string') {
res.status(400).json({ error: 'clientId required' });
return;
@@ -1766,12 +1844,31 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption
res.status(400).json({ error: 'boundSlugPrefixes must be null or an array of slug-prefix strings' });
return;
}
const result = await oauthProvider.rescopeClient(clientId, { sourceId, federatedRead, boundSlugPrefixes });
// WP4: tri-state surface rescope — omitted = untouched, null = clear
// (surface + surface_set_by both NULL), value = set + operator lock
// (mirrors the CLI's --surface verbs|starter|full|clear).
if (surface !== undefined && surface !== null &&
surface !== 'verbs' && surface !== 'starter' && surface !== 'full') {
res.status(400).json({ error: 'surface must be null or one of: verbs, starter, full' });
return;
}
const result = await oauthProvider.rescopeClient(clientId, { sourceId, federatedRead, boundSlugPrefixes, surface });
// WP4 (amendment 32 / ENG-8): every surface mutation writes an audit
// row — this endpoint, the rescope CLI, and the request_tools persist.
if (surface !== undefined) {
await writeSurfaceChangeAudit(engine, {
actor: 'admin-api',
client_id: clientId,
old: result.surfaceOld ?? null,
new: result.surface ?? null,
via: 'admin_api',
});
}
res.json(result);
} catch (e) {
const message = e instanceof Error ? e.message : 'Rescope failed';
const status = /No OAuth client found/.test(message) ? 404
: /Invalid source_id|requires --source|cannot be empty|does not exist|cannot be an empty list|bound_slug_prefixes entr/.test(message) ? 400
: /Invalid source_id|requires --source|cannot be empty|does not exist|cannot be an empty list|bound_slug_prefixes entr|--surface must be/.test(message) ? 400
: 500;
res.status(status).json({ error: message });
}
@@ -1866,13 +1963,46 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption
// ---------------------------------------------------------------------------
// MCP tool calls (bearer auth + scope enforcement)
// ---------------------------------------------------------------------------
// MEMORY_VERBS v1: surface filter applies AFTER the localOnly filter; the
// same set feeds dispatch as allowedOps so hidden ops are uncallable, not
// just unlisted [c2].
const surface = options.surface ?? 'full';
const mcpOperations = filterOpsForSurface(operations.filter(op => !op.localOnly), surface);
const surfaceAllowedOps: ReadonlySet<string> | undefined =
surface === 'full' ? undefined : new Set(mcpOperations.map(o => o.name));
// MEMORY_VERBS v1 + WP4 (D2): the server-resolved surface is the CEILING.
// The per-REQUEST effective surface — min(ceiling, client row surface ??
// config default), clamped by the GBRAIN_MCP_FORCE_SURFACE kill switch
// (narrow-only, FOV-6a) — is resolved inside the /mcp handler so a rescope
// or config flip takes effect on the client's next request without a
// restart, and the dispatch allow-set is recomputed per request
// (amendment 20). The surface filter applies AFTER the localOnly filter;
// the same set feeds dispatch as allowedOps so hidden ops are uncallable,
// not just unlisted [c2].
const serverSurfaceCeiling: McpSurface = options.surface ?? 'full';
const mcpOperationsBase = operations.filter(op => !op.localOnly);
/**
* WP4 (D2): resolve this request's effective surface from the caller's
* verified auth. The config default (`mcp.default_surface_dcr`) is read
* dual-plane ONLY when the client row carries no usable surface the
* common full-surface path pays no extra config read. Unknown row values
* are ignored with a warn-once per client (amendment 18). Never throws:
* surface resolution must not take a request down. On a default-surface
* read failure the LAST successfully read default (per process) still
* applies, so a transient config outage cannot silently widen a client
* that normally resolves narrower than the ceiling; with no prior read,
* the ceiling is the only floor available (pre-WP4 behavior).
*/
let lastKnownDefaultSurface: McpSurface | null = null;
async function resolveEffectiveSurface(authInfo: AuthInfo): Promise<{ ceiling: McpSurface; effective: McpSurface }> {
const ceiling = clampSurface(serverSurfaceCeiling);
// min() can never go below the narrowest surface: a 'verbs' ceiling makes
// the row/default resolution a no-op, so skip the awaited config read.
if (ceiling === 'verbs') return { ceiling, effective: ceiling };
const rowSurface = resolveClientRowSurface(authInfo.surface, authInfo.clientId);
if (rowSurface !== null) return { ceiling, effective: minSurface(ceiling, rowSurface) };
try {
const dflt = await resolveDefaultClientSurface(engine, config);
lastKnownDefaultSurface = dflt ?? null;
return { ceiling, effective: minSurface(ceiling, dflt ?? ceiling) };
} catch {
return { ceiling, effective: minSurface(ceiling, lastKnownDefaultSurface ?? ceiling) };
}
}
// v0.36.x #1076: MCP Streamable HTTP spec — GET /mcp opens an optional SSE
// backchannel for server-initiated messages. gbrain's transport is stateless
@@ -1895,6 +2025,14 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption
// for legacy tokens or when the JOIN row's client_name is NULL.
const agentName = authInfo.clientName ?? authInfo.clientId;
// WP4 (D2): per-request effective surface + fail-closed allow-set,
// recomputed per request (amendment 20) so rescopes/request_tools
// persists take effect on the next request with zero restart.
const { ceiling: surfaceCeiling, effective: surface } = await resolveEffectiveSurface(authInfo);
const mcpOperations = filterOpsForSurface(mcpOperationsBase, surface);
const surfaceAllowedOps: ReadonlySet<string> | undefined =
surface === 'full' ? undefined : new Set(mcpOperations.map(o => o.name));
// Create a fresh MCP server per request (stateless)
const server = new Server(
{ name: 'gbrain', version: VERSION },
@@ -1902,10 +2040,48 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption
);
server.setRequestHandler(ListToolsRequestSchema, async () => {
// WP1 honest catalog: the advertised list is exactly what THIS token
// can call. Three per-request filters, cheapest first:
// 1. token scope — a read-only token never sees admin/write tools;
// 2. bound-client fence — a slug-bound client never sees ops the
// dispatch fence would deny (same predicate, cannot drift);
// 3. publish gates — gated ops (skills/advisor) are hidden while
// their gate is off; the resolver never throws (read failure =
// hidden, matching the default-off consent posture) so a config
// hiccup costs at most the 4 gated tools, never the whole list.
// Call-time enforcement (hasScope / fence / assertPublishEnabled)
// stays as the fail-closed backstop for all three layers.
// Both per-request config reads are independent — issue them
// concurrently (one RTT of latency on network Postgres, not two).
const [gateDisabled, strictParamsMode] = await Promise.all([
disabledOpsForPublishGates(engine, config),
resolveStrictParamsMode(engine, config),
]);
// FOV-4: `agent` deliberately implies only itself, which would strand
// agent-only tokens with ZERO discovery — ops flagged `agentCallable`
// (request_tools) are visible to (and callable by, below) agent scope
// in addition to their declared scope.
const visibleOps = mcpOperations.filter(op =>
(hasScope(authInfo.scopes, op.scope ?? 'read')
|| (op.agentCallable === true && hasScope(authInfo.scopes, 'agent')))
&& opAllowedForBoundClient(authInfo, op)
&& !gateDisabled.has(op.name),
);
// WP3 (amendment 14): ONE schema mapper — the inline map this handler
// carried is unified onto buildToolDefs so the byte-pin test covers the
// transport consumers actually use. strict_params is read dual-plane
// PER REQUEST (same restart-free property as the publish gates above):
// 'reject' closes each schema with additionalProperties:false and
// declares the _meta/dry_run passthrough keys (D14.1).
const strictParams = strictParamsMode === 'reject';
const tools = buildToolDefs(visibleOps, { strictParams });
// v0.28.10: log every JSON-RPC method, not just successful tools/call.
// Pre-fix, /admin/api/requests showed nothing for clients that only
// ever called tools/list, and the v0.26.3 persistence regression test
// asserting >= 2 rows after tools/list + tools/call was unreachable.
// Amendment 23 stopgap (full list-size telemetry deferred): the row's
// params carry the listed-tool count so per-token-class list sizes are
// queryable (`params->>'tool_count'`) without new telemetry plumbing.
const latency = Date.now() - startTime;
try {
await executeRawJsonb(
@@ -1913,7 +2089,7 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption
`INSERT INTO mcp_request_log (token_name, agent_name, operation, latency_ms, status, params)
VALUES ($1, $2, $3, $4, $5, $6::jsonb)`,
[authInfo.clientId, agentName, 'tools/list', latency, 'success'],
[null],
[{ tool_count: tools.length }],
);
} catch { /* best effort */ }
broadcastEvent({
@@ -1924,22 +2100,7 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption
status: 'success',
timestamp: new Date().toISOString(),
});
return {
tools: mcpOperations.map(op => ({
name: op.name,
description: op.description,
inputSchema: {
type: 'object' as const,
properties: Object.fromEntries(
Object.entries(op.params).map(([k, v]) => [k, paramDefToSchema(v)]),
),
required: Object.entries(op.params).filter(([, v]) => v.required).map(([k]) => k),
},
// MEMORY_VERBS v1: ToolAnnotations emitted only when the op defines
// them — existing tools stay byte-identical (mirrors buildToolDefs).
...(op.annotations ? { annotations: op.annotations } : {}),
})),
};
return { tools };
});
server.setRequestHandler(CallToolRequestSchema, async (request) => {
@@ -1977,17 +2138,26 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption
// hierarchy. Plain string includes() at this site would have made
// sources_admin tokens look like they couldn't even read.)
const requiredScope = op.scope || 'read';
if (!hasScope(authInfo.scopes, requiredScope)) {
// FOV-4: agentCallable carve-out mirrors the tools/list filter above —
// an op listed for an agent-only token must not scope-deny at call time.
const scopeSatisfied = hasScope(authInfo.scopes, requiredScope)
|| (op.agentCallable === true && hasScope(authInfo.scopes, 'agent'));
if (!scopeSatisfied) {
// v0.28.10: persist scope-rejected attempts. Same operator-visibility
// motivation as the unknown-op path — and it makes the v0.26.3
// persistence regression test reliable across both rejection paths.
// Amendment 33: a call-time scope deny is a LIST-LEVEL denial (the
// tools/list filter uses this same hasScope predicate, so the op was
// never advertised to this token — the client ignored or staled its
// list, or list/call drifted). status='denied_after_list' makes the
// honest-catalog metric a one-line count that trends to zero.
const latency = Date.now() - startTime;
try {
await executeRawJsonb(
engine,
`INSERT INTO mcp_request_log (token_name, agent_name, operation, latency_ms, status, error_message, params)
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb)`,
[authInfo.clientId, agentName, name, latency, 'error', `insufficient_scope: requires '${requiredScope}'`],
[authInfo.clientId, agentName, name, latency, 'denied_after_list', `insufficient_scope: requires '${requiredScope}'`],
[null],
);
} catch { /* best effort */ }
@@ -1996,7 +2166,7 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption
operation: name,
scopes: authInfo.scopes.join(','),
latency_ms: latency,
status: 'error',
status: 'denied_after_list',
error: { code: 'insufficient_scope', message: `requires '${requiredScope}'` },
timestamp: new Date().toISOString(),
});
@@ -2060,12 +2230,17 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption
try {
toolResult = await dispatchToolCall(engine, name, params as Record<string, unknown> | undefined, {
remote: true,
// WP1/D7: network transport — the dispatch-layer localOnly
// backstop keys off this marker.
transport: 'http',
takesHoldersAllowList: tokenAllowList,
sourceId: tokenSourceId,
metaHook: getBrainHotMemoryMeta,
// MEMORY_VERBS v1: fail-closed surface enforcement + usage attribution.
...(surfaceAllowedOps ? { allowedOps: surfaceAllowedOps } : {}),
surface,
// WP4 (D2): request_tools bounds its catalog + persist by this.
surfaceCeiling,
// v0.31 follow-up fix: thread auth so the whoami op (and any
// future scope-aware handlers) can introspect the caller. The
// original D12/eE1 refactor moved dispatch into dispatchToolCall
@@ -2113,17 +2288,24 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption
// dispatchToolCall serializes the error into the content text;
// for the audit log we re-extract a message string for the
// mcp_request_log error_message column. Best-effort parse.
// Amendment 33 / D10: op-level denials the list should have
// prevented (publish-gate backstop `config_key=...`, bound-client
// fence op-level deny `fence=op`) log status='denied_after_list'
// instead of plain 'error' — the honest-catalog trend-to-zero
// metric. Argument-level fence denials carry no marker and stay
// 'error' (legitimate for a listed op).
let errMsg = 'unknown_error';
try {
const parsed = JSON.parse(toolResult.content[0]?.text ?? '{}');
errMsg = parsed.error?.message ?? parsed.message ?? errMsg;
} catch { /* ignore */ }
const errStatus = requestLogStatusForResult(toolResult);
try {
await executeRawJsonb(
engine,
`INSERT INTO mcp_request_log (token_name, agent_name, operation, latency_ms, status, error_message, params)
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb)`,
[authInfo.clientId, agentName, name, latency, 'error', errMsg],
[authInfo.clientId, agentName, name, latency, errStatus, errMsg],
[logParamsObj],
);
} catch { /* best effort */ }
@@ -2133,19 +2315,24 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption
params: broadcastParams,
scopes: authInfo.scopes.join(','),
latency_ms: latency,
status: 'error',
status: errStatus,
error: { code: 'op_error', message: errMsg },
timestamp: new Date().toISOString(),
});
return toolResult;
}
// WP3 (amendment 13): warn-mode observability. A success whose _meta
// carries a non-empty warnings array logs as 'success_with_warnings' so
// the reject-flip decision is evidence-based (count per client via the
// status column). Warn CONTENTS (the raw unknown keys) are never logged.
const successStatus = requestLogStatusForResult(toolResult);
try {
await executeRawJsonb(
engine,
`INSERT INTO mcp_request_log (token_name, agent_name, operation, latency_ms, status, params)
VALUES ($1, $2, $3, $4, $5, $6::jsonb)`,
[authInfo.clientId, agentName, name, latency, 'success'],
[authInfo.clientId, agentName, name, latency, successStatus],
[logParamsObj],
);
} catch { /* best effort */ }
@@ -2155,7 +2342,7 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption
params: broadcastParams,
scopes: authInfo.scopes.join(','),
latency_ms: latency,
status: 'success',
status: successStatus,
timestamp: new Date().toISOString(),
});
return toolResult;
+11 -3
View File
@@ -2,6 +2,7 @@ import { spawnSync } from 'node:child_process';
import type { BrainEngine } from '../core/engine.ts';
import { startMcpServer } from '../mcp/server.ts';
import { VERB_NAMES } from '../core/verbs.ts';
import { redirectStdoutLoggingToStderr } from '../core/console-prefix.ts';
// Maximum time the stdio path will wait for engine.disconnect() (PGLite
// close + advisory lock release) before forcing exit. Keeps a wedged
@@ -57,7 +58,7 @@ export interface ServeOptions {
// (which unconditionally attaches a 'data' listener to real
// process.stdin and would pollute the test runner's stdin handle).
// Defaults to the real implementation when omitted.
startMcpServer?: (engine: BrainEngine, opts?: { surface?: 'verbs' | 'full' }) => Promise<void>;
startMcpServer?: (engine: BrainEngine, opts?: { surface?: 'verbs' | 'starter' | 'full' }) => Promise<void>;
// Test seam for the parent-process watchdog. The default
// (`readLiveParentPid`) reads the live kernel PPID via `ps` on POSIX
// because `process.ppid` is captured at process creation and does not
@@ -172,8 +173,9 @@ export async function runServe(
const isHttp = args.includes('--http');
// MEMORY_VERBS v1: tool-surface mode. Flag > config `mcp_surface` > 'full'.
// 'verbs' exposes exactly the five protocol verbs (the quickstart surface);
// 'full' (default) keeps every operation — existing installs see no change.
// 'verbs' exposes exactly the seven protocol verbs (the quickstart surface);
// 'starter' the ~20-op daily-driver set; 'full' (default) keeps every
// operation — existing installs see no change.
const { parseSurfaceFlag, resolveSurface } = await import('../mcp/surface.ts');
const { loadConfig } = await import('../core/config.ts');
const surface = resolveSurface(parseSurfaceFlag(args), loadConfig());
@@ -243,6 +245,12 @@ export async function runServe(
: 'Starting GBrain MCP server (stdio)...',
);
// stdout is reserved for JSON-RPC frames from here on. Ops that run
// in-process (sync_brain -> performSync -> embed --stale) emit progress
// via slog/console.log, which would otherwise land on stdout and make
// the MCP client log "Failed to parse JSONRPC message" for every line.
redirectStdoutLoggingToStderr();
installStdioLifecycle(engine, args, opts);
const start = opts.startMcpServer ?? startMcpServer;
+21 -6
View File
@@ -57,6 +57,7 @@ import {
parseSourceConfig,
normalizeSourceConfig,
isSourceFederated,
sourceFederationState,
type SourceRow as LoadedSourceRow,
} from '../core/sources-load.ts';
@@ -470,8 +471,14 @@ async function runList(engine: BrainEngine, args: string[]): Promise<void> {
// Human-readable table.
console.log('SOURCES');
console.log('───────');
for (const e of entries) {
const fedMark = e.federated ? 'federated' : (e as any).archived ? '⚠ archived' : 'isolated';
for (let i = 0; i < entries.length; i++) {
const e = entries[i];
// Explicit `federated: false` (`sources unfederate`) fully isolates a
// source's reads in both directions; an absent key ('unset') only keeps
// it out of OTHER anchors' reads — its own unqualified reads still widen
// outward (see sourceFederationState). Collapsing both to "isolated"
// overstates what an unset flag does.
const fedMark = (e as any).archived ? '⚠ archived' : sourceFederationState(rows[i].config);
const pathStr = e.local_path ?? '(no local path)';
const sync = e.last_sync_at ? `last sync ${e.last_sync_at}` : 'never synced';
console.log(` ${e.id.padEnd(20)} ${fedMark.padEnd(12)} ${String(e.page_count).padStart(6)} pages ${sync}`);
@@ -1556,10 +1563,18 @@ Subcommands:
per finding via .gbrain-scan-allow) and on
tracked deny-list files (*.pglite, .env*,
*.pem, *.key, .gbrain/**). Refuses remotes not
verifiably private via gh; single-flight (a
concurrent push exits 0 as "skipped"); pushes
even on a clean tree. Writes
~/.gbrain/bootstrap/push-status.json.
verifiably private verified via REST, falling
back to pure git protocol where gh is blocked
(cloud proxies); private verdicts cached 1h.
Unverified-remote overrides (self-hosted git
you trust; every use warns loudly): the flag
above, GBRAIN_ALLOW_UNVERIFIED_REMOTE=1, or
"gbrain config set push.allow_unverified_remote
true" (file-plane reaches detached hook
children). Single-flight (a concurrent push
exits 0 as "skipped"); pushes even on a clean
tree. Writes per-root status under
~/.gbrain/bootstrap/.
unharden <id> Remove durability cron/hook/credential wiring.
Source id: [a-z0-9-]{1,32}. Immutable citation key.
+211 -44
View File
@@ -15,7 +15,7 @@
* - Workers supervisor health from the audit JSONL
* - Queue live minion_jobs counts BY status (NO time window
* old stuck jobs are exactly what status surfaces)
* - Autopilot daemon PID liveness via kill -0 probe
* - Autopilot daemon PID liveness plus gbrain-autopilot identity probe
*
* Exit codes (kubectl-style):
* 0 snapshot produced successfully (even if it carries warnings)
@@ -23,11 +23,12 @@
* 2 usage error (bad --section value)
*
* Thin-client mode (isThinClient(cfg)):
* - Sync + Cycle route through `get_status_snapshot` MCP op (admin scope)
* - Locks/Workers/Queue/Autopilot render "local-only — N/A on remote brain"
* because they're host-local concerns; pretending the local install's
* local-host operational state is the remote brain's would lie to the
* operator.
* - Sync + Cycle + Workers + Queue route through `get_status_snapshot`
* MCP op (admin scope; workers/queue are snapshot-v2 sections an old
* server that omits them renders a graceful "upgrade the remote" line)
* - Locks/Autopilot render "local-only — N/A on remote brain" because
* they're host-local concerns; pretending the local install's local-host
* operational state is the remote brain's would lie to the operator.
*
* --json emits a stable envelope:
* { schema_version: 1, sync, cycle, locks?, workers?, queue?, autopilot? }
@@ -40,6 +41,10 @@ import { existsSync, readFileSync } from 'node:fs';
import { gbrainPath, loadConfig, isThinClient } from '../core/config.ts';
import { callRemoteTool, unpackToolResult } from '../core/mcp-client.ts';
import { VERSION } from '../version.ts';
import {
classifyAutopilotLockHolder,
type AutopilotLockProbeDeps,
} from '../core/autopilot-lock.ts';
import {
buildSyncStatusReport,
type SyncStatusReport,
@@ -89,6 +94,47 @@ export interface QueueCounts {
dead: number;
}
/** Per-queue waiting depth + oldest-waiting age (snapshot v2 `queue.by_queue`). */
export interface QueueDepthRow {
queue: string;
depth: number;
oldest_waiting_age_seconds: number | null;
}
/** Snapshot v2 `queue` section: status counts + per-queue waiting depths. */
export interface RemoteQueueSnapshot {
counts: QueueCounts;
by_queue: QueueDepthRow[];
}
/**
* Snapshot v2 `workers` section composed like `gbrain jobs supervisor
* status` (pidfile first, queue-scoped DB singleton lock as the
* HOME-independent fallback authority, #2227).
*/
export interface RemoteWorkersSnapshot {
supervisor_alive: boolean;
detected_via: 'pidfile' | 'db_lock' | null;
live_lock_active: boolean;
last_completed_at: string | null;
}
/**
* Amendment 26: a snapshot v2 section that failed to compute degrades to this
* marker instead of failing the whole snapshot.
*/
export interface SectionUnavailable {
error: 'unavailable';
}
/**
* Thin-client skew marker: the remote server predates snapshot v2 (missing
* key / schema_version 1), so this section cannot be reported yet.
*/
export interface RemoteUnsupported {
remote_unsupported: true;
}
export interface WorkerSummary {
crashes_24h: number;
clean_exits_24h: number;
@@ -114,8 +160,8 @@ export interface StatusReport {
sync?: SyncStatusReport;
cycle?: CycleSnapshot;
locks?: LockRow[] | { local_only_remote: true };
workers?: WorkerSummary | { local_only_remote: true };
queue?: QueueCounts | { local_only_remote: true };
workers?: WorkerSummary | RemoteWorkersSnapshot | SectionUnavailable | RemoteUnsupported | { local_only_remote: true };
queue?: QueueCounts | RemoteQueueSnapshot | SectionUnavailable | RemoteUnsupported | { local_only_remote: true };
autopilot?: AutopilotStatus | { local_only_remote: true };
warnings?: string[];
/** #1984: true when a --deadline-ms budget elided one or more sections. */
@@ -256,7 +302,8 @@ async function buildLocks(engine: BrainEngine): Promise<LockRow[]> {
}
}
async function buildQueueCounts(engine: BrainEngine): Promise<QueueCounts> {
// Exported for `src/core/operations.ts:get_status_snapshot` (snapshot v2 queue section).
export async function buildQueueCounts(engine: BrainEngine): Promise<QueueCounts> {
type Row = { status: string; count: string | number };
const counts: QueueCounts = { active: 0, waiting: 0, completed: 0, failed: 0, dead: 0 };
try {
@@ -275,6 +322,87 @@ async function buildQueueCounts(engine: BrainEngine): Promise<QueueCounts> {
return counts;
}
/**
* Per-queue waiting depth + oldest-waiting age. Generalizes the doctor's
* queue_health oldest-age SQL past its embed-backfill-only filter: EVERY
* queue with waiting work reports here, name-agnostic. Perf note: WHERE
* constrains only `status` the SECOND column of the (queue, status,
* updated_at) wedge index so no prefix access exists and this GROUP BY
* full-scans minion_jobs today. Acceptable at snapshot frequency over pruned
* waiting sets; a partial (queue, created_at) WHERE status='waiting' index
* is the fix if it becomes hot.
*/
export async function buildQueueDepths(engine: BrainEngine): Promise<QueueDepthRow[]> {
const rows = await engine.executeRaw<{
queue: string;
depth: number | string;
oldest_waiting_age_seconds: number | string | null;
}>(
`SELECT queue,
count(*)::int AS depth,
EXTRACT(EPOCH FROM (now() - min(created_at)))::int AS oldest_waiting_age_seconds
FROM minion_jobs
WHERE status = 'waiting'
GROUP BY queue
ORDER BY depth DESC`,
);
return rows.map((r) => ({
queue: r.queue,
depth: Number(r.depth),
oldest_waiting_age_seconds:
r.oldest_waiting_age_seconds === null ? null : Number(r.oldest_waiting_age_seconds),
}));
}
/**
* Snapshot v2 workers section. Same detection ladder as `gbrain jobs
* supervisor status` (src/commands/jobs.ts): the pidfile is HOME-derived and
* lies across split-$HOME deployments, so the queue-scoped DB singleton lock
* (#1849/#2227) is probed as the fallback authority. `last_completed_at` is
* the freshest completed-job timestamp evidence a worker recently finished
* something, regardless of how it was launched.
*
* `opts` exists as a test seam (scratch pidFile) production callers take
* the defaults.
*/
export async function buildWorkersSnapshot(
engine: BrainEngine,
opts: { pidFile?: string; queue?: string } = {},
): Promise<RemoteWorkersSnapshot> {
const { readSupervisorPid } = await import('../core/minions/supervisor-pid.ts');
const { DEFAULT_PID_FILE, supervisorLockId, SUPERVISOR_LOCK_TTL_MIN } = await import(
'../core/minions/supervisor.ts'
);
const pidStatus = readSupervisorPid(opts.pidFile ?? DEFAULT_PID_FILE);
let lockLive = false;
try {
const { inspectLock, isLockHolderLive } = await import('../core/db-lock.ts');
const snap = await inspectLock(engine, supervisorLockId(opts.queue ?? 'default'));
lockLive = snap !== null && isLockHolderLive(snap, SUPERVISOR_LOCK_TTL_MIN);
} catch {
/* pre-migration brains lack the locks table — pidfile signal stands */
}
let lastCompleted: string | null = null;
try {
const rows = await engine.executeRaw<{ last_completed: string | Date | null }>(
`SELECT max(updated_at) AS last_completed FROM minion_jobs WHERE status = 'completed'`,
);
const v = rows[0]?.last_completed ?? null;
lastCompleted = v ? (v instanceof Date ? v.toISOString() : new Date(v).toISOString()) : null;
} catch {
/* no minion_jobs table — leave null */
}
return {
supervisor_alive: pidStatus.running || lockLive,
detected_via: pidStatus.running ? 'pidfile' : lockLive ? 'db_lock' : null,
live_lock_active: lockLive,
last_completed_at: lastCompleted,
};
}
function buildWorkerSummary(): WorkerSummary {
let crashes_24h = 0;
let clean_exits_24h = 0;
@@ -297,8 +425,10 @@ function buildWorkerSummary(): WorkerSummary {
return { crashes_24h, clean_exits_24h, by_cause, last_event_ts };
}
function buildAutopilotStatus(): AutopilotStatus {
const lockPath = gbrainPath('autopilot.lock');
export function buildAutopilotStatus(
lockPath: string = gbrainPath('autopilot.lock'),
deps: AutopilotLockProbeDeps = {},
): AutopilotStatus {
const lockfile_present = existsSync(lockPath);
let pid: number | null = null;
let running = false;
@@ -308,16 +438,8 @@ function buildAutopilotStatus(): AutopilotStatus {
const parsed = parseInt(raw, 10);
if (Number.isFinite(parsed) && parsed > 0) {
pid = parsed;
try {
// kill -0 probes liveness without sending a real signal. Throws ESRCH
// if the PID is gone, EPERM if alive but owned by another user (which
// still tells us "something with that PID exists").
process.kill(parsed, 0);
running = true;
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
running = code === 'EPERM';
}
const holder = classifyAutopilotLockHolder(parsed, process.pid, deps);
running = holder.state === 'alive-autopilot' || holder.state === 'alive-unknown';
}
} catch {
/* unreadable lockfile, leave pid=null/running=false */
@@ -427,7 +549,9 @@ async function buildThinClientReport(
mode: 'thin-client',
};
if (want('sync') || want('cycle')) {
// Snapshot v2 also backs workers + queue (locks/autopilot stay host-local).
const remoteBacked: Section[] = ['sync', 'cycle', 'workers', 'queue'];
if (remoteBacked.some((s) => want(s))) {
try {
const payload = await withSectionDeadline(
(async () => {
@@ -440,50 +564,69 @@ async function buildThinClientReport(
{},
opts.deadlineMs && opts.deadlineMs > 0 ? { timeoutMs: opts.deadlineMs } : {},
);
return unpackToolResult<{
schema_version: number;
version?: string;
sync: SyncStatusReport;
cycle: CycleSnapshot;
}>(raw);
return unpackToolResult<RemoteSnapshotPayload>(raw);
})(),
opts.deadlineMs && opts.deadlineMs > 0 ? opts.deadlineMs : undefined,
() => {
report.partial = true;
// Only name the sections the caller actually requested; the remote
// fetch backs both sync+cycle, but `--section sync` must not report
// `cycle` (a section it excluded) as stale. Matches the local path.
const elided: Section[] = [
...(want('sync') ? (['sync'] as Section[]) : []),
...(want('cycle') ? (['cycle'] as Section[]) : []),
];
// fetch backs sync+cycle+workers+queue, but `--section sync` must not
// report a section it excluded as stale. Matches the local path.
const elided = remoteBacked.filter((s) => want(s));
report.stale_sections = [...(report.stale_sections ?? []), ...elided];
warnings.push('remote snapshot exceeded the --deadline-ms budget (returned stale)');
},
);
if (payload) {
// #1984: surface the brain server's version for thin-client parity.
if (payload.version) report.remote_version = payload.version;
if (want('sync')) report.sync = payload.sync;
if (want('cycle')) report.cycle = payload.cycle;
}
if (payload) applyRemoteSnapshot(report, payload, want);
} catch (err) {
warnings.push(`remote snapshot failed: ${(err as Error).message}`);
}
}
if (want('locks')) report.locks = { local_only_remote: true };
if (want('workers')) report.workers = { local_only_remote: true };
if (want('queue')) report.queue = { local_only_remote: true };
if (want('autopilot')) report.autopilot = { local_only_remote: true };
if (warnings.length > 0) report.warnings = warnings;
return report;
}
/** Wire shape of the `get_status_snapshot` payload across server versions. */
export interface RemoteSnapshotPayload {
schema_version: number;
version?: string;
sync: SyncStatusReport;
cycle: CycleSnapshot;
/** v2+ — absent on schema_version-1 servers. */
workers?: RemoteWorkersSnapshot | SectionUnavailable;
/** v2+ — absent on schema_version-1 servers. */
queue?: RemoteQueueSnapshot | SectionUnavailable;
}
/**
* Map a remote snapshot payload onto the thin-client report. Exported for the
* skew fixture test: a NEW thin-client against an OLD (schema_version 1)
* server must degrade the v2-only sections to a graceful marker, never crash
* or pretend local data is remote data.
*/
export function applyRemoteSnapshot(
report: StatusReport,
payload: RemoteSnapshotPayload,
want: (s: Section) => boolean,
): void {
// #1984: surface the brain server's version for thin-client parity.
if (payload.version) report.remote_version = payload.version;
if (want('sync')) report.sync = payload.sync;
if (want('cycle')) report.cycle = payload.cycle;
// v2 sections: an old server omits the keys entirely (schema_version 1) —
// degrade to the skew marker rather than the misleading "N/A on remote".
if (want('workers')) report.workers = payload.workers ?? { remote_unsupported: true };
if (want('queue')) report.queue = payload.queue ?? { remote_unsupported: true };
}
// ---------------------------------------------------------------------------
// Human render
// ---------------------------------------------------------------------------
function renderHuman(report: StatusReport): string {
// Exported for the thin-client skew fixture test (old-server payload render).
export function renderHuman(report: StatusReport): string {
const lines: string[] = [];
lines.push('');
lines.push('GBrain Status');
@@ -556,6 +699,15 @@ function renderHuman(report: StatusReport): string {
lines.push('Workers (last 24h):');
if ('local_only_remote' in report.workers) {
lines.push(' local-only — N/A on remote brain');
} else if ('remote_unsupported' in report.workers) {
lines.push(' not reported by this brain server (predates snapshot v2) — upgrade the remote gbrain to see workers');
} else if ('error' in report.workers) {
lines.push(' unavailable (remote section failed to compute)');
} else if ('supervisor_alive' in report.workers) {
const w = report.workers;
const via = w.detected_via ? ` (via ${w.detected_via})` : '';
lines.push(` supervisor: ${w.supervisor_alive ? 'alive' : 'not detected'}${via} db_lock=${w.live_lock_active ? 'live' : 'none'}`);
lines.push(` last completed job: ${w.last_completed_at ?? 'never'}`);
} else {
const w = report.workers;
lines.push(` crashes=${w.crashes_24h} clean_exits=${w.clean_exits_24h}`);
@@ -573,6 +725,21 @@ function renderHuman(report: StatusReport): string {
lines.push('Queue (live):');
if ('local_only_remote' in report.queue) {
lines.push(' local-only — N/A on remote brain');
} else if ('remote_unsupported' in report.queue) {
lines.push(' not reported by this brain server (predates snapshot v2) — upgrade the remote gbrain to see queue depth');
} else if ('error' in report.queue) {
lines.push(' unavailable (remote section failed to compute)');
} else if ('counts' in report.queue) {
const q = report.queue.counts;
lines.push(
` active=${q.active} waiting=${q.waiting} failed=${q.failed} dead=${q.dead} completed=${q.completed}`,
);
for (const row of report.queue.by_queue) {
const age = row.oldest_waiting_age_seconds != null
? ` oldest_waiting=${Math.round(row.oldest_waiting_age_seconds / 60)}m`
: '';
lines.push(` [${row.queue}] depth=${row.depth}${age}`);
}
} else {
const q = report.queue;
lines.push(
@@ -592,7 +759,7 @@ function renderHuman(report: StatusReport): string {
if (a.running) {
lines.push(` running (PID ${a.pid})`);
} else if (a.lockfile_present) {
lines.push(` stale lockfile (PID ${a.pid ?? '?'} not alive). Run \`gbrain autopilot --install\` to restart.`);
lines.push(` stale lockfile (PID ${a.pid ?? '?'} is not a live autopilot process). Run \`gbrain autopilot --install\` to restart.`);
} else {
lines.push(' not running. Install with `gbrain autopilot --install`.');
}
+190 -15
View File
@@ -19,6 +19,7 @@ import {
isSkippablePath,
resolveAutoSkipThreshold,
DEFAULT_SOURCE_ID,
ownsGlobalSyncAnchor,
} from '../core/sync.ts';
import {
computeSyncDelta,
@@ -1038,7 +1039,71 @@ export function discoverGitRoot(inputPath: string): string {
* gbrain.yml, or a semantic overlap) propagates better to leave this
* self-heal wedged with a clear error than commit unknown content.
*/
function createSyncBaselineCommit(repoPath: string): void {
/**
* Classify a caught `git rev-parse --verify --quiet HEAD` failure for the
* baseline-commit guard. A genuinely UNBORN HEAD makes git exit with status
* EXACTLY 1 and nothing on stderr precisely what `--verify --quiet` emits
* for an unresolvable HEAD (verified empirically: unborn => exit 1, empty
* stderr; born => exit 0). Every OTHER failure shape a 30s timeout (killed
* by signal, so `status` is null), an index/ref lock (`fatal: Unable to
* create ...lock`, non-empty stderr), or any nonzero-but-not-1 exit — is NOT
* proof the repo is empty; it is the transient-probe-failure class that
* corrupted the live brain on 2026-08-10. Return 'unborn' ONLY for the clean
* signal so the caller fails CLOSED on 'ambiguous'. Pure (no I/O) so the
* distinction is unit-testable without fault injection.
*/
export function classifyHeadProbeError(err: unknown): 'unborn' | 'ambiguous' {
const e = (err ?? {}) as { status?: number | null; signal?: string | null; stderr?: unknown };
const stderr = e.stderr == null ? '' : String(e.stderr).trim();
return e.status === 1 && e.signal == null && stderr === '' ? 'unborn' : 'ambiguous';
}
export function createSyncBaselineCommit(repoPath: string): void {
// Fail-closed backstop (2026-08-10 auto-init incident). This function's
// ENTIRE contract is "snapshot an unborn/uninitialized repo as its FIRST
// commit". It must NEVER run on a repo that already has commits: doing so
// stacks a spurious `gbrain: initial commit (auto-init by sync)` commit ON
// TOP of real history and, on a case-insensitive filesystem, re-cases the
// whole tree (`projects` -> `Projects`) as a `git add -A` side effect.
//
// Both call sites are *supposed* to reach here only on an unborn/non-git
// repo, but each infers "unborn" from a FAILURE to observe git state
// (`discoverGitRoot` threw / `git rev-parse HEAD` threw), and those probes
// ALSO fail transiently — a 30s timeout on a large brain, or a concurrent
// `gbrain-sync` holding a git lock — against a fully-populated repo, which
// is exactly what corrupted the live brain on 2026-08-10. So we cannot
// trust "the caller said it's unborn"; verify POSITIVELY here — and, since
// this very probe is subject to the same transient failures, accept ONLY a
// CLEAN unborn signal. A born HEAD (exit 0) OR an ambiguous probe failure
// (timeout / lock / other) both REFUSE, so the backstop is fail-closed
// against every corruption path, not just the born-HEAD one.
// `classifyHeadProbeError` isolates that born/unborn/ambiguous distinction
// as a pure, unit-tested predicate.
//
// Known non-incident edge (B2, documented not fixed): an orphan branch
// (`git switch --orphan`) in a repo with history elsewhere probes as unborn
// and would still be baselined. A gbrain brain is never in that state; it is
// not the incident (no stacking on real history, no re-case of other
// branches), so it is left as a limitation rather than complicating the
// guard with a `rev-list --all` "commits anywhere" probe.
let headState: 'born' | 'unborn' | 'ambiguous';
try {
git(repoPath, ['rev-parse', '--verify', '--quiet', 'HEAD'], [], 30000, { silenceStderr: true });
headState = 'born';
} catch (err) {
headState = classifyHeadProbeError(err);
}
if (headState !== 'unborn') {
throw new Error(
`Refusing to create a sync baseline commit in ${repoPath}: HEAD probe is ` +
`'${headState}', expected a clean unborn HEAD. 'born' = the repo already ` +
`has commits (real history); 'ambiguous' = the HEAD check failed ` +
`transiently (30s timeout, or a concurrent gbrain-sync holding a git ` +
`lock), which is NOT proof the repo is empty. Committing either way ` +
`would stack a bogus auto-init commit on real history and re-case the ` +
`tree on a case-insensitive filesystem.`,
);
}
// #2964: db_only exclusion is computed directly from loadStorageConfig
// and passed to `git add` as pathspecs — deliberately NOT via
// manageGitignore/.gitignore, for two independent reasons:
@@ -1311,7 +1376,7 @@ async function isAnchorOwnedSyncPath(
}
}
async function writeSyncAnchor(
export async function writeSyncAnchor(
engine: BrainEngine,
sourceId: string | undefined,
which: 'repo_path' | 'last_commit',
@@ -1323,6 +1388,11 @@ async function writeSyncAnchor(
// git-intrinsic committer time of the HEAD we just synced). `undefined` keeps
// the legacy 2-column write; `null` clears the column (git unavailable).
newestContentEpochMs?: number | null,
// #2114: the repo dir this anchor write is FOR. Required to guard the
// legacy branch's `last_commit` writes (where `value` is a hash, not a
// dir). `repo_path` writes self-describe via `value`. Callers that omit
// it on a legacy-path last_commit write keep pre-#2114 behavior.
repoDir?: string,
): Promise<void> {
if (sourceId) {
const col = which === 'repo_path' ? 'local_path' : 'last_commit';
@@ -1350,9 +1420,25 @@ async function writeSyncAnchor(
}
return;
}
// Legacy no-sourceId path (pre-v0.18 global config). Modern sync always
// resolves a sourceId (incl. 'default'), so newest_content_at is written via
// the sourceId branch above; the default source is not stuck on NULL.
// Legacy no-sourceId path (pre-v0.18 global config; also reached when a
// caller could not resolve a source for the dir — dream --dir on an
// unregistered directory, minion sync with an unmatched repoPath). #2114:
// these globals describe THE brain repo, and this branch used to write
// them unconditionally — a full-sync fallback against a foreign directory
// silently repointed put_page write-through and poisoned the incremental
// anchor. Refuse to move them for a directory that isn't the brain repo.
const anchorDir = which === 'repo_path' ? value : repoDir;
if (anchorDir !== undefined) {
const { owns, configured } = await ownsGlobalSyncAnchor(engine, undefined, anchorDir);
if (!owns) {
serr(
`[sync] sync.${which} stays at ${configured ?? '(unset)'} — not moving the ` +
`global anchor for "${anchorDir}". To make that directory the brain repo: ` +
`gbrain config set sync.repo_path "${anchorDir}"`,
);
return;
}
}
await engine.setConfig(`sync.${which}`, value);
}
@@ -1953,10 +2039,41 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
) {
throw err;
}
serr(`[gbrain] auto-recovery: git-initializing brain dir ${repoPath} (no git repo found).`);
git(repoPath, ['init', '--quiet']);
createSyncBaselineCommit(repoPath);
gitContextRoot = realpathSync(discoverGitRoot(repoPath));
// 2026-08-10 incident guard. `discoverGitRoot` is a 30s-bounded
// `git rev-parse --show-toplevel` that walks UP; it can throw for reasons
// OTHER than "no git repo" — a transient timeout on a large brain, or a
// concurrent `gbrain-sync` holding a git lock — on a directory that IS a
// git repo, whether the repo root is `repoPath` itself OR an ANCESTOR
// (subdir-anchored brain, the #753/#774 monorepo pattern). Trusting a
// single throw and running `git init` (a no-op reinit at repoPath, or a
// NEW nested repo shadowing the ancestor) + baseline-commit stacks a
// spurious auto-init commit and re-cases the tree on a case-insensitive
// filesystem. So do NOT self-heal on one throw — re-probe once:
// - re-probe SUCCEEDS => the first throw was transient and the repo
// (own or ancestor) is real; use it, never init/commit.
// - re-probe THROWS but `.git` is present at repoPath => a real but
// unreadable repo (corrupt, broken gitlink, or a persistent transient)
// — NEVER init/commit over it; surface the original error.
// - re-probe THROWS and no `.git` at repoPath => genuinely not a git
// repo anywhere up the tree; self-heal.
// The createSyncBaselineCommit chokepoint is the fail-closed backstop if
// this ever reaches a baseline on a repo that turns out to have commits.
let reprobedRoot: string | null = null;
try {
reprobedRoot = discoverGitRoot(repoPath);
} catch {
reprobedRoot = null;
}
if (reprobedRoot !== null) {
gitContextRoot = realpathSync(reprobedRoot);
} else if (existsSync(join(repoPath, '.git'))) {
throw err;
} else {
serr(`[gbrain] auto-recovery: git-initializing brain dir ${repoPath} (no git repo found).`);
git(repoPath, ['init', '--quiet']);
createSyncBaselineCommit(repoPath);
gitContextRoot = realpathSync(discoverGitRoot(repoPath));
}
}
const rawScopeRoot = opts.srcSubpath ? join(repoPath, opts.srcSubpath) : repoPath;
if (!existsSync(rawScopeRoot)) {
@@ -2493,7 +2610,7 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
// (#1794): advance to the PINNED target, and clear any checkpoint (a resume
// whose remaining range turned out to have no syncable changes still
// completes cleanly here).
await writeSyncAnchor(engine, opts.sourceId, 'last_commit', pin, commitTimeMs(gitContextRoot, pin));
await writeSyncAnchor(engine, opts.sourceId, 'last_commit', pin, commitTimeMs(gitContextRoot, pin), gitContextRoot);
await engine.setConfig('sync.last_run', new Date().toISOString());
await writeChunkerVersion(engine, opts.sourceId, String(CHUNKER_VERSION));
await clearOpCheckpoint(engine, ckpt.paths);
@@ -3401,7 +3518,7 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
// "fresh". The checkpoint rows clear here — CONVERGENCE CONTRACT: sync
// convergence == IMPORT convergence; downstream extract/facts/embed is
// decoupled (its own resumable stale sweeps).
await writeSyncAnchor(engine, opts.sourceId, 'last_commit', pin, commitTimeMs(gitContextRoot, pin));
await writeSyncAnchor(engine, opts.sourceId, 'last_commit', pin, commitTimeMs(gitContextRoot, pin), gitContextRoot);
await engine.setConfig('sync.last_run', new Date().toISOString());
await writeSyncAnchor(engine, opts.sourceId, 'repo_path', anchorPath);
await writeChunkerVersion(engine, opts.sourceId, String(CHUNKER_VERSION));
@@ -3530,10 +3647,68 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
// covered regardless.
const extractOpts = opts.sourceId ? { sourceId: opts.sourceId } : undefined;
if (!opts.noExtract && totalChanges > 100 && pagesAffected.length > 0) {
// #2849: above the size gate the deferred extraction must be DURABLY
// QUEUED, not just hinted. The autopilot cycle's extract phase is
// slug-scoped (an up_to_date follow-up sync hands it an empty
// pagesAffected), so a webhook-driven large sync left
// `links_extracted_at` unstamped FOREVER unless an operator ran
// `gbrain extract --stale` by hand. Submit a source-scoped stale-sweep
// job bound to the consumed commit (idempotency key) so repeated
// webhook deliveries / sync retries of the same commit coalesce onto
// one job. The sweep itself is the watermark scan — it picks up the
// pages this run imported AND any banked across resumed runs.
// Best-effort: queue submission failure falls back to the hint-only
// behavior (the pages stay stale + visible to doctor, never mis-stamped).
let queuedJobId: number | string | null = null;
try {
const { MinionQueue } = await import('../core/minions/queue.ts');
const { STALE_TIME_BUDGET_MS } = await import('./extract.ts');
const queue = new MinionQueue(engine);
const payload = {
stale: true,
...(opts.sourceId ? { sourceId: opts.sourceId } : {}),
reason: 'sync_size_gate',
// Bound to the PIN this run drained to (== headCommit unless resuming
// a stored target), not live HEAD — the sweep covers what we imported.
deferred_commit: pin,
};
// The stale sweep has its own internal wall-clock budget
// (GBRAIN_EXTRACT_TIME_BUDGET_MS-derived); without an explicit
// timeout_ms the job would inherit the tight null-default and get
// wall-clock-killed mid-sweep (#1737 class). 5-min headroom.
const timeoutMs = STALE_TIME_BUDGET_MS + 5 * 60 * 1000;
// NO maxWaiting here: with an unscoped (NULL-sourceId) payload the
// queue's coalesce filter matches ANY waiting 'extract' job (e.g. a
// remediation-submitted {mode:'links'} row) and returns THAT job —
// silently dropping the sweep while we log "queued". The idempotency
// key alone is the dedup for repeat submissions toward the same pin.
const key = `extract-stale:${opts.sourceId ?? 'default'}:${pin}`;
const isLiveSweep = (j: { status: string; data: Record<string, unknown> }): boolean =>
j.data?.stale === true && ['waiting', 'delayed', 'active'].includes(j.status);
let job = await queue.add('extract', payload, { idempotency_key: key, timeout_ms: timeoutMs });
if (!isLiveSweep(job)) {
// The key slot holds a FINISHED row: a prior sweep toward this pin
// that completed BEFORE this run's pages landed (checkpoint-resume /
// blocked-advance re-sync of the same target). Those pages went
// stale after that sweep's watermark pass, so coalescing onto the
// finished row would strand them — queue a fresh sweep under a
// run-unique key. (An 'active' sweep is safe to coalesce onto: its
// end-of-run staleRemaining re-count chains a continuation.)
job = await queue.add('extract', payload, {
idempotency_key: `${key}:${Date.now()}`,
timeout_ms: timeoutMs,
});
}
// Only claim "queued" once we verified the returned row IS a live
// stale sweep — never trust queue.add's row blind.
if (isLiveSweep(job)) queuedJobId = job.id;
} catch { /* best-effort — hint below still tells the operator */ }
slog(
` Large sync: deferring link/timeline extraction. ` +
`Run 'gbrain extract --stale${opts.sourceId ? ` --source-id ${opts.sourceId}` : ''}' ` +
`(or let the autopilot cycle's extract phase sweep it).`,
` Large sync: deferring link/timeline extraction` +
(queuedJobId != null
? ` — queued stale-sweep job #${queuedJobId} (source: ${opts.sourceId ?? 'default'}); a running jobs worker will consume it.`
: `.`) +
` Run 'gbrain extract --stale${opts.sourceId ? ` --source-id ${opts.sourceId}` : ''}' to extract now.`,
);
}
if (!opts.noExtract && totalChanges <= 100 && pagesAffected.length > 0) {
@@ -3752,7 +3927,7 @@ async function performFullSync(
const advanceFull = async (): Promise<void> => {
// Persist sync state so the next sync is incremental. Routed through
// writeSyncAnchor so --source pins the right sources row.
await writeSyncAnchor(engine, opts.sourceId, 'last_commit', headCommit, newestCommitMs(gitContextRoot));
await writeSyncAnchor(engine, opts.sourceId, 'last_commit', headCommit, newestCommitMs(gitContextRoot), gitContextRoot);
await engine.setConfig('sync.last_run', new Date().toISOString());
await writeSyncAnchor(engine, opts.sourceId, 'repo_path', anchorPath);
await writeChunkerVersion(engine, opts.sourceId, String(CHUNKER_VERSION));
+18 -4
View File
@@ -210,6 +210,13 @@ async function cmdAdd(engine: BrainEngine, args: string[], sourceId?: string): P
const brainDir = await resolveBrainDir(engine, dirArg ?? null);
await withPageLock(slug, async () => {
// Resolve the page BEFORE touching the markdown. getPageId exits 1 when the
// page isn't in the brain; doing this after writeBody left a .md file
// carrying a take with no DB row — invisible to scorecard/calibration but
// present on disk, so a later `takes add` would number the next row past a
// take the DB never saw. update/supersede/resolve already resolve first.
const pageId = await getPageId(engine, slug, sourceId);
const path = pageFilePath(brainDir, slug);
const body = readBodyOrEmpty(path);
const { body: nextBody, rowNum } = upsertTakeRow(body, {
@@ -217,8 +224,6 @@ async function cmdAdd(engine: BrainEngine, args: string[], sourceId?: string): P
});
writeBody(path, nextBody);
// Mirror to DB. Page may not be in DB yet if not synced — caller must run sync first.
const pageId = await getPageId(engine, slug, sourceId);
await engine.addTakesBatch([{
page_id: pageId, row_num: rowNum, claim, kind, holder, weight,
since_date: since, source, active: true, superseded_by: null,
@@ -619,12 +624,13 @@ async function cmdExtract(engine: BrainEngine, rest: string[]): Promise<void> {
const sub = rest[0];
if (sub !== '--from-pages') {
process.stderr.write(
'Usage: gbrain takes extract --from-pages [--yes] [--dry-run] [--source-id <id>] [--max-pages N (clamped to 1000)] [--include-covered] [--holder <name>]\n' +
'Usage: gbrain takes extract --from-pages [--yes] [--dry-run] [--json] [--source-id <id>] [--max-pages N (clamped to 1000)] [--include-covered] [--holder <name>]\n' +
'Runs progress: pages that already hold takes are skipped, so repeat runs sweep a large corpus in slices. --include-covered rescans everything (refresh).\n',
);
process.exit(1);
}
const dryRun = rest.includes('--dry-run');
const json = rest.includes('--json');
const skipConfirm = rest.includes('--yes');
const sourceIdx = rest.indexOf('--source-id');
const sourceIdFilter = sourceIdx >= 0 ? rest[sourceIdx + 1] : undefined;
@@ -662,9 +668,17 @@ async function cmdExtract(engine: BrainEngine, rest: string[]): Promise<void> {
holder,
});
if (result.llm_unavailable) {
process.stderr.write(`[takes extract] chat gateway unavailable (no API key configured).\n`);
if (json) {
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
} else {
process.stderr.write(`[takes extract] chat gateway unavailable (no API key configured).\n`);
}
process.exit(2);
}
if (json) {
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
return;
}
process.stdout.write(
`takes extract --from-pages: ${result.claims_extracted} claim(s) from ${result.pages_scanned} page(s)` +
(dryRun ? ' (dry-run)' : '') + '\n',
+12
View File
@@ -1,6 +1,17 @@
import { operations } from '../core/operations.ts';
import { buildToolDefs } from '../mcp/tool-defs.ts';
/**
* `gbrain --tools-json` the public CLI tool-catalog contract.
*
* The legacy keys (`name`, `description`, `parameters` with the compact
* `"type"` / `"type?"` strings) are preserved VERBATIM downstream scripts
* parse them. WP3 adds the full JSON Schema (param descriptions, enums,
* defaults, required array) additively under a new `schema` key per tool,
* generated by the same buildToolDefs mapper the MCP transports serve.
*/
export function printToolsJson() {
const defsByName = new Map(buildToolDefs(operations).map(def => [def.name, def]));
const tools = operations.map(op => ({
name: op.name,
description: op.description,
@@ -10,6 +21,7 @@ export function printToolsJson() {
`${v.type}${v.required ? '' : '?'}`,
]),
),
schema: defsByName.get(op.name)!.inputSchema,
}));
console.log(JSON.stringify(tools, null, 2));
+42 -11
View File
@@ -473,19 +473,39 @@ export async function runPostUpgrade(args: string[] = []): Promise<void> {
// `ze_sunset_notice_shown` (same pattern as the search-mode banner).
try {
const shown = await engine.getConfig('ze_sunset_notice_shown');
const { DEFAULT_EMBEDDING_MODEL } = await import('../core/ai/defaults.ts');
const { DEFAULT_EMBEDDING_MODEL, ZEROENTROPY_SUNSET_DATE } = await import('../core/ai/defaults.ts');
const effectiveModel = cfgSchema.embedding_model ?? DEFAULT_EMBEDDING_MODEL;
const rerankerModel = await engine.getConfig('search.reranker.model');
// Effective reranker via the plane search actually reranks with
// (mode bundle + search.reranker.* overrides) — the bare config
// key is unset by default while balanced/tokenmax rerank with the
// bundle's zeroentropyai model. Same resolution as the
// provider_sunset doctor check.
let rerankerModel: string | undefined;
try {
const { loadSearchModeConfig, resolveSearchMode } = await import('../core/search/mode.ts');
const knobs = resolveSearchMode(await loadSearchModeConfig(engine));
if (knobs.reranker_enabled) rerankerModel = knobs.reranker_model;
} catch { /* no reranker-exposure claim */ }
const onZeEmbedding = effectiveModel.startsWith('zeroentropyai:');
const onZeReranker = !!rerankerModel?.startsWith('zeroentropyai:');
if (shown !== 'true' && (onZeEmbedding || onZeReranker)) {
// Paste-ready --dim from the ACTUAL column width (config can
// drift): keeping the current width avoids a needless dimension
// transition + index rebuild when the target supports it.
let colDims: number | null = null;
try {
const { readContentChunksEmbeddingDim } = await import('../core/embedding-dim-check.ts');
colDims = (await readContentChunksEmbeddingDim(engine)).dims;
} catch { /* fresh brain — omit --dim */ }
const dimFlag = colDims ? ` --dim ${colDims}` : '';
console.log('');
console.log('═══════════════════════════════════════════════════════════════');
console.log('[gbrain] ACTION REQUIRED: ZeroEntropy hosted API sunsets 2026-09-04.');
console.log(`[gbrain] ACTION REQUIRED: ZeroEntropy hosted API sunsets ${ZEROENTROPY_SUNSET_DATE}.`);
if (onZeEmbedding) {
console.log(`[gbrain] This brain embeds with ${effectiveModel}. After the sunset,`);
console.log('[gbrain] semantic retrieval STOPS WORKING (queries can no longer be');
console.log('[gbrain] embedded against your existing vectors).');
console.log('[gbrain] semantic retrieval STOPS WORKING entirely — your EXISTING');
console.log('[gbrain] vectors become unqueryable (queries embed through the same');
console.log('[gbrain] endpoint), not just new content.');
}
if (onZeReranker) {
console.log(`[gbrain] The reranker (${rerankerModel}) also sunsets; search falls`);
@@ -493,17 +513,28 @@ export async function runPostUpgrade(args: string[] = []): Promise<void> {
}
console.log('═══════════════════════════════════════════════════════════════');
console.log('');
console.log('Migrate before the sunset (resumable; preview cost first):');
console.log(' gbrain migrate embeddings --to <provider:model> --dry-run');
console.log(' gbrain migrate embeddings --to <provider:model>');
console.log('Two fixes, either works:');
console.log('');
console.log('Self-hosting zembed-1 (weights are Apache-2.0) via llama-server /');
console.log('ollama also works and preserves your existing vectors — point');
console.log('embedding at the local endpoint instead of migrating.');
console.log('[1] Self-host the same model — zembed-1 weights are Apache-2.0. Serve');
console.log(' them via llama-server or Ollama and point the config at the local');
console.log(' endpoint. Keeps every existing vector; NO re-embed at all. See');
console.log(' docs/guides/embedding-migration.md ("Self-hosting instead of migrating").');
console.log('');
console.log('[2] Migrate to another provider (resumable; preview cost first):');
console.log(` gbrain migrate embeddings --to <provider:model>${dimFlag} --dry-run`);
console.log(` gbrain migrate embeddings --to <provider:model>${dimFlag}`);
if (colDims) {
console.log(` (--dim ${colDims} is this brain's current index width — keep it to`);
console.log(' avoid a needless schema rebuild when the target supports it.)');
}
if (onZeReranker) {
console.log('');
console.log('Reranker: gbrain config set search.reranker.enabled false (or pick another).');
}
console.log('');
console.log(`\`gbrain doctor\` will keep flagging this until the brain is off the`);
console.log('provider (check name: provider_sunset).');
console.log('');
await engine.setConfig('ze_sunset_notice_shown', 'true');
}
} catch {
+272
View File
@@ -0,0 +1,272 @@
/**
* advisor/collect-mcp-client-fit.ts E3: MCP surface right-sizing over
* `mcp_request_log` (amendment 29 + D12).
*
* Two checks, both read-only, both riding the shared usage reader
* (src/core/mcp-usage.ts the same hygiene rules as the E4 CLI and the
* derive-starter-ops script):
*
* (a) Per-client fit: a client resolving to the FULL surface whose 30d
* distinct-op set fits inside STARTER_OPS is paying the ~100-tool
* catalog for a starter-sized workload. Finding carries the exact fix:
* `gbrain auth rescope-client <id> --surface starter`.
* (b) Set-level drift (the standing STARTER_OPS curator): top-10 most-used
* ops (ranked by CLIENT COUNT, not raw calls D12) missing from
* STARTER_OPS, and starter members (excluding
* ALWAYS_INCLUDED_STARTER_OPS verbs + whoami + request_tools + the
* agent lane) unused for 90d.
*
* Privacy (amendment 29): when the advisor runs REMOTE (ctx.remote), client
* identifiers are REDACTED to aggregate counts ("2 clients fit the starter
* surface run gbrain advisor on the host for details"). Full per-client
* detail is local-CLI-only. Op NAMES are not client identifiers and stay
* visible in the drift finding on both surfaces.
*
* D12 exclusions: automation-shaped clients (>90% of calls are the
* context_pack/delta boundary verbs the hook-lane/turn-context signature;
* there is no registered-name convention to key on, the hook lane is stdio
* and never logs) are excluded from BOTH checks via
* `ClientOpUsage.likely_automation`.
*
* Dismiss/snooze: the skillpack nag-state engine (escalate-then-suppress,
* ceiling 3) with its OWN state file (`~/.gbrain/advisor-usage-nag-state.json`)
* so declines never pollute the skillpack ledger. The nag "version" is a
* fingerprint of the finding's inputs a changed usage profile re-surfaces a
* suppressed finding, an unchanged one goes quiet after the ceiling. Local
* runs only: the remote advisor op is strictly read-only (no nag writes).
*
* Alert threshold (amendment 29): a client needs >= MIN_CALLS_FOR_FIT calls
* in the window before a fit finding fires one whoami probe is not a
* workload.
*/
import { gbrainPath } from '../config.ts';
import { operations } from '../operations.ts';
import { readClientOpUsage, type ClientOpUsage } from '../mcp-usage.ts';
import {
STARTER_OPS,
ALWAYS_INCLUDED_STARTER_OPS,
isMcpSurface,
resolveDefaultClientSurface,
} from '../../mcp/surface.ts';
import {
loadNagState,
saveNagState,
findNag,
upsertNag,
decideNagAction,
recordNagDisplay,
type NagState,
} from '../skillpack/nag-state.ts';
import type { AdvisorCollector, AdvisorContext, AdvisorFinding } from './types.ts';
/** Minimum 30d call volume before a per-client fit finding fires. */
export const MIN_CALLS_FOR_FIT = 10;
/** How many top-used ops the drift check inspects for starter membership. */
export const DRIFT_TOP_N = 10;
/** Window for the "starter member unused" drift arm. */
export const DRIFT_UNUSED_WINDOW_DAYS = 90;
/** Nag-state file for this collector (separate from the skillpack ledger). */
export function usageNagStatePath(): string {
return _nagPathOverride ?? gbrainPath('advisor-usage-nag-state.json');
}
let _nagPathOverride: string | null = null;
/** Test seam: point the nag state at a tmp file (null restores the default). */
export function __setUsageNagStatePathForTests(path: string | null): void {
_nagPathOverride = path;
}
/**
* Gate a finding through the nag engine (local runs only). Returns true when
* the finding should surface; records the display so the ceiling counts.
*/
function nagAllows(state: NagState, packName: string, fingerprint: string): { show: boolean; next: NagState } {
const key = { brain_id: 'host', source_id: 'mcp-usage', pack_name: packName };
const prior = findNag(state, key);
const decision = decideNagAction(prior, { pack_version: fingerprint });
if (!decision.show) return { show: false, next: state };
const entry = recordNagDisplay(prior, key, { pack_version: fingerprint, nowIso: new Date().toISOString() });
return { show: true, next: upsertNag(state, entry) };
}
/** Resolve the surface a client row would get WITHOUT the server ceiling. */
function clientResolvedSurface(
rowSurface: unknown,
defaultSurface: string | null,
): string {
if (isMcpSurface(rowSurface)) return rowSurface;
return defaultSurface ?? 'full';
}
async function readClientSurfaces(ctx: AdvisorContext): Promise<Map<string, unknown> | null> {
try {
const rows = await ctx.engine.executeRaw<{ client_id: string; surface: unknown }>(
`SELECT client_id, surface FROM oauth_clients`,
);
return new Map(rows.map((r) => [r.client_id, r.surface]));
} catch {
// Pre-v127 brain (no surface column) or no oauth_clients table: the fit
// finding's fix command could not work anyway — skip check (a).
return null;
}
}
export const collectMcpClientFit: AdvisorCollector = {
id: 'mcp-client-fit',
collect: async (ctx) => {
const findings: AdvisorFinding[] = [];
let usage30: ClientOpUsage[];
try {
usage30 = await readClientOpUsage(ctx.engine, { days: 30 });
} catch {
return []; // mcp_request_log absent / engine quirk → no findings
}
const real30 = usage30.filter((u) => !u.likely_automation);
// Nag state: loaded once, saved once, LOCAL runs only (remote is read-only).
const local = !ctx.remote;
let nag = local ? loadNagState({ statePath: usageNagStatePath() }) : null;
// ---- (a) per-client starter fit -------------------------------------
const surfaces = await readClientSurfaces(ctx);
if (surfaces !== null) {
let defaultSurface: string | null = null;
try {
defaultSurface = await resolveDefaultClientSurface(ctx.engine, ctx.config);
} catch {
defaultSurface = null;
}
const fits: ClientOpUsage[] = [];
for (const u of real30) {
if (u.total_calls < MIN_CALLS_FOR_FIT) continue;
if (!surfaces.has(u.token_name)) continue; // legacy bearer token — no per-client surface row to rescope
const resolved = clientResolvedSurface(surfaces.get(u.token_name), defaultSurface);
if (resolved !== 'full') continue; // already narrowed (row or DCR default)
if (!u.distinct_ops.every((op) => STARTER_OPS.has(op))) continue;
fits.push(u);
}
if (fits.length > 0) {
if (!local) {
// Amendment 29: remote output carries aggregate counts ONLY.
findings.push({
id: 'mcp_starter_fit_aggregate',
severity: 'info',
title: `${fits.length} MCP client${fits.length === 1 ? '' : 's'} fit the starter surface — run \`gbrain advisor\` on the host for details.`,
detail:
'Each has 30 days of usage entirely inside STARTER_OPS while resolving to the full ' +
'catalog. Client identifiers are shown on the host CLI only.',
fix: { command_argv: null },
collector: 'mcp-client-fit',
ask_user: true,
});
} else {
for (const u of fits) {
const fingerprint = `fit:${u.distinct_ops.join(',')}`;
const gate = nagAllows(nag!, `fit:${u.token_name}`, fingerprint);
nag = gate.next;
if (!gate.show) continue;
findings.push({
id: `mcp_starter_fit:${u.token_name}`,
severity: 'info',
title: `MCP client "${u.token_name}" used only starter-surface ops for 30d (${u.distinct_ops.length} ops, ${u.total_calls} calls) but sees the full catalog.`,
detail:
'A right-sized catalog means fewer wrong-tool calls and a smaller tools/list ' +
'prompt for that agent. The rescope takes effect on its next request; the client ' +
'should re-issue tools/list. (HTTP clients only — stdio usage is not logged. ' +
'Automation-shaped clients, >90% context_pack/delta, are excluded.)',
fix: { command_argv: ['gbrain', 'auth', 'rescope-client', u.token_name, '--surface', 'starter'] },
collector: 'mcp-client-fit',
ask_user: true,
});
}
}
}
}
// ---- (b) set-level drift (the standing curator) ----------------------
try {
// Rank by CLIENT COUNT (D12: distinct-op sets, not raw call volume).
const clientCountByOp = new Map<string, number>();
for (const u of real30) {
for (const op of u.distinct_ops) {
clientCountByOp.set(op, (clientCountByOp.get(op) ?? 0) + 1);
}
}
const topOps = [...clientCountByOp.entries()]
.sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1))
.slice(0, DRIFT_TOP_N)
.map(([op]) => op);
// localOnly ops are never proposable for a network surface (mirrors
// derive-starter-ops); a logged localOnly name (old rows, CLI-actor
// audit exception) must not produce an unactionable recommendation.
const localOnlyOps = new Set(operations.filter((o) => o.localOnly).map((o) => o.name));
const missingFromStarter = topOps.filter((op) => !STARTER_OPS.has(op) && !localOnlyOps.has(op));
const usage90 = await readClientOpUsage(ctx.engine, { days: DRIFT_UNUSED_WINDOW_DAYS });
const seen90 = new Set<string>();
for (const u of usage90) {
if (u.likely_automation) continue;
for (const op of u.distinct_ops) seen90.add(op);
}
// Always-included-by-construction members (shared with surface.ts +
// derive-starter-ops) never count as drift — including the agent lane,
// whose usage would otherwise flag it "unused" forever.
const unusedStarter = [...STARTER_OPS]
.filter((op) => !ALWAYS_INCLUDED_STARTER_OPS.has(op) && !seen90.has(op))
.sort();
// Only meaningful once there is real traffic to curate against.
if (real30.length > 0 && (missingFromStarter.length > 0 || unusedStarter.length > 0)) {
const parts: string[] = [];
if (missingFromStarter.length > 0) {
parts.push(`top-used ops missing from STARTER_OPS: ${missingFromStarter.join(', ')}`);
}
if (unusedStarter.length > 0) {
parts.push(`starter members unused for ${DRIFT_UNUSED_WINDOW_DAYS}d: ${unusedStarter.join(', ')}`);
}
const fingerprint = `drift:${missingFromStarter.join(',')}|${unusedStarter.join(',')}`;
let show = true;
if (local) {
const gate = nagAllows(nag!, 'starter-drift', fingerprint);
nag = gate.next;
show = gate.show;
}
if (show) {
findings.push({
id: 'mcp_starter_ops_drift',
severity: 'info',
title: `STARTER_OPS has drifted from production usage (${parts.length === 2 ? 'both directions' : parts[0]!.split(':')[0]}).`,
detail:
parts.join('; ') +
'. Re-derive with `bun run scripts/derive-starter-ops.ts` and paste the proposed ' +
'block into src/mcp/surface.ts (the monotonicity test pins verbs ⊆ starter ⊆ full). ' +
'Automation-shaped clients are excluded from these stats.',
fix: { command_argv: null },
collector: 'mcp-client-fit',
ask_user: true,
});
}
}
} catch {
/* drift check is best-effort — never blocks the fit findings */
}
// Persist nag displays (local only, best-effort — a failed write costs one
// extra display, never a report failure).
if (local && nag) {
try {
saveNagState(nag, { statePath: usageNagStatePath() });
} catch {
/* best effort */
}
}
return findings;
},
};
+5 -10
View File
@@ -6,21 +6,16 @@
* the self-upgrade refresh path.
*/
import { readUpdateCache } from '../self-upgrade.ts';
import { pendingUpgradeVersion } from '../self-upgrade.ts';
import type { AdvisorCollector } from './types.ts';
export const collectVersion: AdvisorCollector = {
id: 'version',
collect: async (ctx) => {
let latest: string | undefined;
try {
const entry = readUpdateCache();
if (entry && entry.marker.kind === 'upgrade_available' && entry.marker.latest) {
latest = entry.marker.latest;
}
} catch {
return [];
}
// Shared stale/foreign-cache guard: fresh cache only, and only an upgrade
// strictly newer than the RUNNING version (pendingUpgradeVersion owns the
// rule; never throws).
const latest = pendingUpgradeVersion(ctx.version, Date.now());
if (!latest) return [];
return [
{
+8
View File
@@ -16,6 +16,14 @@ export interface RecommendedSkill {
}
export const RECOMMENDED: RecommendedSkill[] = [
{
// First on purpose: the day-one "now what?" answer. Every other skill in
// this list gets dramatically better once the brain holds the user's real
// life — cold-start is what fills it.
slug: 'cold-start',
description:
'START HERE. Day-one brain filling: imports your Gmail, calendar, and contacts (via ClawVisor — an OAuth vault, the agent never holds raw tokens) or offline archives, one consented phase at a time. Run it right after install.',
},
{
slug: 'book-mirror',
description:
+2
View File
@@ -17,6 +17,7 @@ import { collectSetupSmells } from './collect-setup-smells.ts';
import { collectUninstalledBrainPack } from './collect-uninstalled-brain-pack.ts';
import { collectUninstalledBundled } from './collect-uninstalled-bundled.ts';
import { collectChronicle } from './collect-chronicle.ts';
import { collectMcpClientFit } from './collect-mcp-client-fit.ts';
/** Deterministic v1 collector order (also the secondary sort key for ranking). */
export const COLLECTORS: AdvisorCollector[] = [
@@ -29,6 +30,7 @@ export const COLLECTORS: AdvisorCollector[] = [
collectUninstalledBrainPack,
collectUninstalledBundled,
collectChronicle,
collectMcpClientFit,
];
const SEV_RANK: Record<AdvisorSeverity, number> = { critical: 0, warn: 1, info: 2 };
+323
View File
@@ -0,0 +1,323 @@
/**
* Base-URL fault classifier for openai-compatible proxies, used by
* `gbrain models doctor` when a chat/embed/rerank probe fails with an `auth`
* (401/403) or `model_not_found` (404) error. It probes the shared `/models`
* endpoint at the configured base URL and each canonical version off its root,
* then classifies the status codes into one targeted verdict, so the doctor
* points at the real fault (missing or wrong version prefix, wrong endpoint, or
* a plain credential / model-name problem) instead of a bare error.
*
* PROVIDER-AGNOSTIC BY DESIGN. The classifier never hardcodes which providers
* serve a `/models` list; it reads the actual status and reasons from that.
* This is deliberate, because openai-compatible endpoints fall into classes that
* behave differently and the set churns:
*
* - AUTHENTICATED `/models` (litellm, ollama, llama-server, most hosted
* OpenAI-shaped APIs). The canonical missing-`/v1` case lives here: bare
* `/models` 404s or 401s while `/v1/models` 200s, so append/switch. On an
* auth failure the control-probe (an unauthenticated GET on the winning
* path) confirms the key when `/models` requires auth, upgrading the hint to
* a definite "the key authenticates at /v1".
* - PUBLIC `/models` (OpenRouter serves `https://openrouter.ai/api/v1/models`
* with no key). A bad key still 200s the configured `/models`, yielding
* `key-or-model` ("URL works, check the key"); and the control-probe returns
* 200 unauthenticated, so we fall to the honest `switch-or-key` rather than
* a false "the key authenticates". Correct with no special-casing, because
* we read the observed statuses.
* - NO `/models` LIST (Voyage serves only `/v1/embeddings`; zeroentropyai, the
* default reranker, only `/v1/models/rerank`). Every probed `/models` 404s.
* On an auth failure that is `wrong-endpoint`, whose wording leads with the
* key (the base URL is demonstrably reachable, since the real call already
* 401'd); on a `model_not_found` failure the classifier stays SILENT, so a
* Voyage model-name typo never gets a misleading "fix your base URL".
*
* Because every verdict comes from observed `/models` status, adding or changing
* a provider needs no change here. `probeOpenAICompat` in `./probes.ts` is a
* separate concern (the providers wizard's unauthenticated, JSON-shape-
* validating, fixed `/v1/models`, boolean-verdict local detector).
*/
import { resolveRecipe } from './model-resolver.ts';
import type { AIGatewayConfig } from './types.ts';
/** Which doctor failure triggered the sweep. Shapes wording and control-probe use. */
type Trigger = 'auth' | 'model_not_found';
/**
* Canonical version prefixes probed against an openai-compatible proxy's
* `/models` endpoint. `/v1` is the OpenAI default; `/v1beta` covers Gemini-shaped
* routes. Each is tried off the base URL's version-root, so they serve both the
* "append to a bare base" and "switch a wrong version" cases. Extend here, not at
* a call site.
*/
const V1_SUFFIX_CANDIDATES = ['/v1', '/v1beta'] as const;
/**
* The base URL with a trailing `/vN` version segment (optional channel suffix
* like `/v1beta`, optional trailing slash) removed: `http://h/v4` -> `http://h`;
* a bare `http://h` is returned unchanged. Canonical candidates are probed off
* this root so a wrong configured version becomes a swap, not an append.
*/
export function versionRoot(baseURL: string): string {
return baseURL.replace(/\/+$/, '').replace(/\/v\d+[a-z]*$/i, '');
}
/**
* GET `url` with the given headers; return the HTTP status, or `null` on any
* transport error (DNS, connection refused, timeout). Never throws. Uses
* `AbortSignal.timeout` to match the gateway's fetch-timeout idiom, and
* `redirect: 'manual'` so a 3xx is never followed: the classifier only reads
* {200,401,403,404}, so following a redirect buys nothing and could forward the
* bearer to another origin.
*/
async function fetchProbeStatus(
url: string,
headers: Record<string, string>,
fetchImpl: typeof fetch,
timeoutMs: number,
): Promise<number | null> {
try {
const res = await fetchImpl(url, {
method: 'GET',
headers,
redirect: 'manual',
signal: AbortSignal.timeout(timeoutMs),
});
return res.status;
} catch {
return null;
}
}
/**
* The diagnosis a `/models` probe sweep supports. Most kinds are auth-only;
* `model-name` is model_not_found-only; `use-verified` carries its trigger
* because its wording differs. `undefined` (returned separately) means the
* statuses were inconclusive.
*/
type BaseUrlVerdict =
| { kind: 'use-verified'; recommend: string; trigger: Trigger } // an alternate's /models 200s: the route is there
| { kind: 'switch-or-key'; recommend: string } // auth: configured unauthorized + alt 200 that is public/unconfirmed
| { kind: 'use-and-key'; recommend: string; status: number } // auth: configured 404, alt 401/403: wrong version AND creds rejected
| { kind: 'key-or-model' } // auth: configured 200: URL reaches a working endpoint
| { kind: 'key-only'; status: number } // auth: configured 401/403, no other version serves /models
| { kind: 'wrong-endpoint' } // auth: no /models at the configured path or any probed version
| { kind: 'model-name' }; // model_not_found: configured 200: URL correct, the model id is wrong
/**
* Probe `/models` at the configured base URL AND each canonical version off its
* root, with the SAME bearer, in parallel, and classify what the status codes
* prove. A `200` means that path serves a models list for the request we sent;
* a `404` means the path is absent; a `401`/`403` means the path exists but
* rejected the credentials. Other statuses (5xx, 429) and transport errors
* prove nothing.
*
* The `trigger` splits behavior: on `auth` the full verdict set runs (including
* a control-probe that confirms the key when an alternate authenticates); on
* `model_not_found` only a real 200 speaks (append/switch, or "URL correct,
* check the model name"), because the key is not in question and a redirect to
* a check-the-key message would misdirect.
*/
async function classifyBaseUrlProbe(
baseURL: string,
headers: Record<string, string>,
candidates: readonly string[],
fetchImpl: typeof fetch,
timeoutMs: number,
trigger: Trigger,
): Promise<BaseUrlVerdict | undefined> {
const base = baseURL.replace(/\/+$/, '');
const root = versionRoot(base);
// Probe set: the configured URL as-is, plus each canonical version off the
// root. Dedup so an already-`/v1` base does not probe `/v1` twice.
const targets: Array<{ suffix: string; configured: boolean }> = [];
const seen = new Set<string>();
const add = (suffix: string, configured: boolean) => {
const url = `${root}${suffix}/models`;
if (!seen.has(url)) {
seen.add(url);
targets.push({ suffix, configured });
}
};
add(base.slice(root.length), true); // configured: '' when bare, else '/vX'
for (const cand of candidates) add(cand, false);
const statuses = await Promise.all(
targets.map(t => fetchProbeStatus(`${root}${t.suffix}/models`, headers, fetchImpl, timeoutMs)),
);
const items = targets.map((t, i) => ({ ...t, status: statuses[i] }));
const authReject = (s: number | null) => s === 401 || s === 403;
const configured = items.find(t => t.configured)!;
const alternates = items.filter(t => !t.configured);
const working = alternates.find(t => t.status === 200);
// model_not_found: the key is not in question, so speak only on positive URL
// evidence (a real 200) and stay silent on everything else.
if (trigger === 'model_not_found') {
if (configured.status === 200) return { kind: 'model-name' };
if (working) return { kind: 'use-verified', recommend: working.suffix, trigger };
return undefined;
}
// auth trigger.
// Configured /models serves a list: the base URL reaches a working route, so
// the failure is credentials or model access, not the URL shape.
if (configured.status === 200) return { kind: 'key-or-model' };
if (working) {
// A version answers 200 with the key. Disambiguate "the key is valid there"
// (confident switch) from "that /models is public, key unproven" (both
// signals) with one unauthenticated control-probe.
const { Authorization: _drop, ...noAuthHeaders } = headers;
const noAuth = await fetchProbeStatus(`${root}${working.suffix}/models`, noAuthHeaders, fetchImpl, timeoutMs);
if (authReject(noAuth)) {
// /models requires auth AND accepted the key -> the key is valid there.
return { kind: 'use-verified', recommend: working.suffix, trigger };
}
// public or unconfirmed -> name both signals.
return { kind: 'switch-or-key', recommend: working.suffix };
}
// Configured path is absent (404).
if (configured.status === 404) {
const rejected = alternates.find(t => authReject(t.status));
if (rejected) return { kind: 'use-and-key', recommend: rejected.suffix, status: rejected.status! };
if (alternates.every(t => t.status === 404)) return { kind: 'wrong-endpoint' };
}
// Configured path exists and rejects the credentials, and no other version
// serves /models: the URL shape is fine, the problem is the key/permissions.
if (authReject(configured.status) && alternates.every(t => t.status === 404 || t.status === null)) {
return { kind: 'key-only', status: configured.status! };
}
return undefined; // inconclusive: 401-everywhere, 5xx/429, or transport errors
}
/** Render a verdict into the operator-facing `fix:` line. */
function verdictHint(verdict: BaseUrlVerdict, baseURL: string): string {
const base = baseURL.replace(/\/+$/, '');
const root = versionRoot(base);
const from = base.slice(root.length); // '' when the base is bare, else '/vX'
const move = (recommend: string) =>
from ? `switch ${from} to ${recommend} (base URL ${root}${recommend})` : `append ${recommend} (base URL ${root}${recommend})`;
switch (verdict.kind) {
case 'use-verified': {
const target = `${root}${verdict.recommend}`;
if (verdict.trigger === 'model_not_found') {
return `${target}/models responds 200 while ${base}/models does not, so the openai-compatible route is at ` +
`${verdict.recommend}${move(verdict.recommend)}. That explains the model-not-found (the request reached the wrong path).`;
}
return `The API key authenticates at ${target} (its /models requires auth and accepted the key), but the ` +
`configured ${base} does not serve that route, so the base URL ${from ? 'has the wrong' : 'is missing its'} ` +
`version prefix — ${move(verdict.recommend)}.`;
}
case 'switch-or-key':
return `The request was unauthorized, so check the API key first. Separately, ${root}${verdict.recommend}/models ` +
`responds 200 (possibly an unauthenticated route), so if the key is correct the base URL should carry ` +
`${verdict.recommend}${move(verdict.recommend)}.`;
case 'use-and-key':
return `The openai-compatible route appears to be at ${root}${verdict.recommend} (its /models responds ` +
`${verdict.status} while the configured ${base}/models returns 404), so ${move(verdict.recommend)}. That route ` +
`also rejects the current credentials (${verdict.status}), so verify the API key or permissions too.`;
case 'key-or-model':
return `${base}/models responds 200 with the configured credentials, so the base URL reaches a working ` +
`openai-compatible endpoint. The failure is the API key or the model's access, not the URL shape.`;
case 'key-only':
return `${base}/models responds ${verdict.status} (the endpoint exists and rejected the credentials) and no ` +
`other common version prefix serves /models, so the base URL shape is not the problem. Check the API key or permissions.`;
case 'wrong-endpoint':
return `The request was unauthorized and no /models list answered at ${base} or the common version prefixes ` +
`(all 404), so this is most likely a key or permissions problem — check the API key. Some providers do not serve ` +
`a /models list; if you expected one, verify the base URL host and path.`;
case 'model-name':
return `${base}/models responds 200, so the base URL is correct. The model-not-found is most likely a wrong or ` +
`unavailable model name — check the model id (and that your key or plan can access it).`;
}
}
/**
* The mutable probe result `maybeAttachVersionSuffixHint` reads and annotates.
* The doctor's `ProbeResult` is structurally compatible; kept minimal here so
* this module does not depend on the command layer.
*/
export interface HintTarget {
status: string;
fix?: string;
}
/**
* Injected config/transport for tests + a per-run memo cache. Real callers pass
* the cache (created once per doctor run) so several probe sites sharing a base
* URL do not each re-run the sweep.
*/
export interface HintDeps {
cfg?: AIGatewayConfig;
fetchImpl?: typeof fetch;
cache?: Map<string, string | undefined>;
}
/**
* On an `auth` (401/403) or `model_not_found` (404) probe failure against an
* openai-compatible proxy, probe `/models` at the configured base URL and each
* canonical version and attach a targeted `fix:` line.
*
* The base URL and bearer resolve through the same `applyOpenAICompatConfig` /
* `applyResolveAuth` / `authToHeaders` seams the live gateway uses (read from
* the live merged config via the gateway's own `requireConfig`, not a config-file
* re-read, which would miss the DB-plane `base_urls` merge), so the probe hits
* the exact path production does, on the same host the operator already trusts
* with the key. Native providers and Azure-templated recipes (bespoke
* `resolveOpenAICompatConfig`) are skipped. Fail-open: any resolution or
* transport error yields no hint and never breaks the probe.
*
* @internal exported for tests.
*/
export async function maybeAttachVersionSuffixHint(
target: HintTarget,
modelStr: string,
touchpoint: 'embedding' | 'expansion' | 'chat' | 'reranker',
deps: HintDeps = {},
): Promise<void> {
const trigger: Trigger | undefined =
target.status === 'auth' ? 'auth' : target.status === 'model_not_found' ? 'model_not_found' : undefined;
if (!trigger) return;
try {
const { requireConfig, applyOpenAICompatConfig, applyResolveAuth, authToHeaders } = await import('./gateway.ts');
// Reuse the gateway's own live-config accessor (the DB + file + env merge),
// not a file re-read. requireConfig throws if the gateway is unconfigured;
// that path can't happen once doctor is probing models, and the fail-open
// catch below absorbs it either way.
const cfg = deps.cfg ?? requireConfig();
const { recipe } = resolveRecipe(modelStr);
if (recipe.tier !== 'openai-compat') return; // native provider — no base-URL trap
if (recipe.resolveOpenAICompatConfig) return; // Azure-templated, non-/vN shape
const { baseURL } = applyOpenAICompatConfig(recipe, cfg);
if (!baseURL || !baseURL.trim()) return;
// Per-run memo: verdicts depend only on (trigger, base URL, recipe), so
// several probe sites sharing a proxy reuse one sweep. Key is non-secret.
const cacheKey = `${trigger}\n${baseURL}\n${recipe.id}`;
if (deps.cache?.has(cacheKey)) {
const cached = deps.cache.get(cacheKey);
if (cached) target.fix = cached;
return;
}
const headers = authToHeaders(applyResolveAuth(recipe, cfg, touchpoint));
const verdict = await classifyBaseUrlProbe(
baseURL,
headers,
V1_SUFFIX_CANDIDATES,
deps.fetchImpl ?? globalThis.fetch,
2000,
trigger,
);
const fix = verdict ? verdictHint(verdict, baseURL) : undefined;
deps.cache?.set(cacheKey, fix);
if (fix) target.fix = fix;
} catch {
// fail open — no hint
}
}
+11
View File
@@ -19,3 +19,14 @@
// steps: {2560, 1280, 640, 320, 160, 80, 40} — see ai/dims.ts.
export const DEFAULT_EMBEDDING_MODEL = 'zeroentropyai:zembed-1';
export const DEFAULT_EMBEDDING_DIMENSIONS = 1280;
/**
* ZeroEntropy announced (2026-07-24) that its hosted API including
* /models/embed and /models/rerank shuts down on this date. Query
* embedding uses the same endpoint as ingestion, so a brain still on a
* `zeroentropyai:*` embedding model loses semantic retrieval ENTIRELY on
* that date (existing vectors become unqueryable, not just new content).
* Single source of truth for the upgrade banner + the `provider_sunset`
* doctor check. Self-hosting the Apache-2.0 zembed-1 weights is unaffected.
*/
export const ZEROENTROPY_SUNSET_DATE = '2026-09-04';
+10 -4
View File
@@ -309,9 +309,12 @@ export function dimsProviderOptions(
// provider serving it) supports Matryoshka truncation via `dimensions`.
// Native sizes: 0.6B=1024, 4B=2560, 8B=4096. Without `dimensions`,
// Ollama returns the native size and brains configured for narrower
// widths hard-fail with a dim-mismatch error. Pattern match the bare
// model name + any `:tag` (e.g. `qwen3-embedding:4b`, `qwen3-embedding:0.6b`).
if (modelId === 'qwen3-embedding' || modelId.startsWith('qwen3-embedding:')) {
// widths hard-fail with a dim-mismatch error. Two naming schemes reach
// this path: Ollama's colon-tag form (`qwen3-embedding:4b`) and the
// hyphenated hub form used by OpenRouter/HF-style routers
// (`qwen/qwen3-embedding-8b` — org prefix stripped to
// `qwen3-embedding-8b` above). Match both.
if (bareModelId === 'qwen3-embedding' || bareModelId.startsWith('qwen3-embedding:') || bareModelId.startsWith('qwen3-embedding-')) {
// Only send `dimensions` when it actually differs from the model's
// native width. Fixed-dim OpenAI-compatible backends serving this
// family (e.g. vLLM) reject the parameter outright with HTTP 400
@@ -323,8 +326,11 @@ export function dimsProviderOptions(
'qwen3-embedding:0.6b': 1024,
'qwen3-embedding:4b': 2560,
'qwen3-embedding:8b': 4096,
'qwen3-embedding-0.6b': 1024,
'qwen3-embedding-4b': 2560,
'qwen3-embedding-8b': 4096,
};
if (QWEN3_EMBEDDING_NATIVE_DIMS[modelId] === dims) return undefined;
if (QWEN3_EMBEDDING_NATIVE_DIMS[bareModelId] === dims) return undefined;
return { openaiCompatible: { dimensions: dims } };
}
// MiniMax embo-01 takes a `type: 'db' | 'query'` field for asymmetric
+19 -5
View File
@@ -124,6 +124,20 @@ const DEFAULT_RERANKER_MODEL = 'zeroentropyai:zerank-2';
let _config: AIGatewayConfig | null = null;
const _modelCache = new Map<string, any>();
/**
* Materialize `applyResolveAuth`'s SDK-shaped result ({apiKey}|{headers}) into
* raw HTTP headers: a Bearer-style apiKey becomes an Authorization header;
* custom/default headers ride alongside (they win on conflict, matching the
* SDK's header-after-apiKey ordering). Single source for the reranker wire path
* and the doctor's base-URL probe.
*/
export function authToHeaders(auth: { apiKey?: string; headers?: Record<string, string> }): Record<string, string> {
return {
...(auth.apiKey ? { Authorization: `Bearer ${auth.apiKey}` } : {}),
...(auth.headers ?? {}),
};
}
/**
* Recover the process-global gateway for foreground command entrypoints that
* were reached without cli.ts's normal engine-connect initialization (#2590).
@@ -675,7 +689,10 @@ export function __setChatTransportForTests(
_chatTransport = fn;
}
function requireConfig(): AIGatewayConfig {
/** The live gateway config, or throw if unconfigured. Exported for the
* `models doctor` base-URL probe (which reads the same merged config the
* gateway calls with, inside its own fail-open guard). */
export function requireConfig(): AIGatewayConfig {
if (!_config) {
throw new AIConfigError(
'AI gateway is not configured. Call configureGateway() during engine connect.',
@@ -3915,10 +3932,7 @@ export async function rerank(input: RerankInput): Promise<RerankResult[]> {
// through `auth.headers` alongside Bearer-style apiKey. The merge below
// materializes both shapes so static-default-headers ride on the reranker
// wire path the same way they ride the SDK paths.
const authHeaders: Record<string, string> = {
...(auth.apiKey ? { Authorization: `Bearer ${auth.apiKey}` } : {}),
...(auth.headers ?? {}),
};
const authHeaders = authToHeaders(auth);
const body = JSON.stringify({
model: parsed.modelId,
query: input.query,
+18 -3
View File
@@ -6,6 +6,10 @@
export interface ProbeResult {
reachable: boolean;
models_endpoint_valid?: boolean;
/** Model ids the endpoint reports as served/pulled (when the models
* endpoint is valid). Lets callers check "is the recipe's model actually
* available" instead of treating daemon-up as model-ready. */
models?: string[];
error?: string;
}
@@ -22,14 +26,25 @@ export async function probeOpenAICompat(baseUrl: string, timeoutMs: number = 100
signal: controller.signal,
headers: { accept: 'application/json' },
});
clearTimeout(timer);
if (!res.ok) return { reachable: true, models_endpoint_valid: false, error: `HTTP ${res.status}` };
if (!res.ok) {
clearTimeout(timer);
return { reachable: true, models_endpoint_valid: false, error: `HTTP ${res.status}` };
}
// Keep the abort timer live through the BODY read — a daemon that accepts,
// returns headers, then stalls the body would otherwise hang past the
// advertised timeout (the probe sits on init's interactive critical path).
const body = await res.json().catch(() => null);
clearTimeout(timer);
if (!body || typeof body !== 'object') {
return { reachable: true, models_endpoint_valid: false, error: 'non-JSON response' };
}
const isList = (body as any).object === 'list' && Array.isArray((body as any).data);
return { reachable: true, models_endpoint_valid: isList };
const models = isList
? ((body as any).data as Array<{ id?: unknown }>)
.map((m) => (typeof m?.id === 'string' ? m.id : ''))
.filter(Boolean)
: undefined;
return { reachable: true, models_endpoint_valid: isList, models };
} catch (e) {
clearTimeout(timer);
return { reachable: false, error: e instanceof Error ? e.message : String(e) };
+4
View File
@@ -35,7 +35,11 @@ export const claudeCli: Recipe = {
// No embedding or expansion touchpoints — chat-only.
chat: {
models: [
'claude-fable-5',
'claude-opus-5',
'claude-opus-4-8',
'claude-opus-4-7',
'claude-sonnet-5',
'claude-sonnet-4-6',
'claude-haiku-4-5-20251001',
],
+64
View File
@@ -0,0 +1,64 @@
import { execFileSync } from 'node:child_process';
export type AutopilotLockHolder =
| { state: 'dead' }
| { state: 'self' }
| { state: 'alive-autopilot' }
| { state: 'alive-foreign' }
| { state: 'alive-unknown' };
export interface AutopilotLockProbeDeps {
isPidAlive?: (pid: number) => boolean;
readProcessCommand?: (pid: number) => string | null;
}
export function isPidAlive(pid: number): boolean {
if (!Number.isFinite(pid) || pid <= 0) return false;
try {
process.kill(pid, 0);
return true;
} catch (error: unknown) {
return (error as NodeJS.ErrnoException).code === 'EPERM';
}
}
export function readProcessCommand(pid: number): string | null {
if (!Number.isFinite(pid) || pid <= 0) return null;
try {
const out = execFileSync('ps', ['-p', String(pid), '-o', 'args='], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
timeout: 1000,
}).trim();
return out.length > 0 ? out : null;
} catch {
return null;
}
}
export function looksLikeGbrainAutopilotCommand(command: string): boolean {
const normalized = command.replace(/\\/g, '/').trim();
if (!/(^|\s)autopilot(\s|$)/i.test(normalized)) return false;
if (/(^|[\/\s])gbrain(?:\.exe)?(\s|$)/i.test(normalized)) return true;
return /(^|\s)(?:\S+\/)?(?:\.{1,2}\/)?(?:src\/)?cli\.(?:ts|js|mjs)(\s|$)/i.test(normalized)
|| /(^|\s)\S*\/src\/cli\.(?:ts|js|mjs)(\s|$)/i.test(normalized);
}
export function classifyAutopilotLockHolder(
pid: number,
currentPid: number = process.pid,
deps: AutopilotLockProbeDeps = {},
): AutopilotLockHolder {
if (!Number.isFinite(pid) || pid <= 0) return { state: 'dead' };
if (pid === currentPid) return { state: 'self' };
const probeAlive = deps.isPidAlive ?? isPidAlive;
if (!probeAlive(pid)) return { state: 'dead' };
const probeCommand = deps.readProcessCommand ?? readProcessCommand;
const command = probeCommand(pid);
if (command === null) return { state: 'alive-unknown' };
return looksLikeGbrainAutopilotCommand(command)
? { state: 'alive-autopilot' }
: { state: 'alive-foreign' };
}
+10
View File
@@ -36,6 +36,8 @@ import T_ACCESS from '../../../templates/bootstrap/ACCESS_POLICY.md.template' wi
import T_MEMORY_README from '../../../templates/bootstrap/memory-README.md.template' with { type: 'file' };
// @ts-ignore
import T_GITIGNORE from '../../../templates/bootstrap/gitignore.template' with { type: 'file' };
// @ts-ignore
import T_CLOUD_SETUP from '../../../templates/bootstrap/cloud-setup-script.sh' with { type: 'file' };
/** Where each rendered file lands, relative to the workspace root. */
export interface BootstrapTemplate {
@@ -47,6 +49,14 @@ export interface BootstrapTemplate {
group: 'identity' | 'contract' | 'scaffold';
}
/** The cloud environment setup script [D16] NOT a rendered template (it is
* pasted into the cloud env config, never written into the workspace). Lives
* as a template file deliberately: inline script strings in src modules would
* bleed their dashed tokens into the CLI flag registry. */
export function loadCloudSetupScript(): string {
return readFileSync(T_CLOUD_SETUP as unknown as string, 'utf8');
}
export const BOOTSTRAP_TEMPLATES: BootstrapTemplate[] = [
{ assetPath: T_AGENTS as unknown as string, dest: 'AGENTS.md', group: 'contract' },
{ assetPath: T_CLAUDE as unknown as string, dest: 'CLAUDE.md', group: 'contract' },
+20 -1
View File
@@ -36,6 +36,8 @@ import {
type AgentManifest,
type InstallReceipt,
} from './format.ts';
import { gitOriginUrl } from './status.ts';
import { type RepoReceipt } from './repo.ts';
import { BootstrapError } from './lock.ts';
export type AttachStepKind = 'register_source' | 'hooks_repair' | 'mcp_add' | 'verify';
@@ -99,7 +101,23 @@ export function attachWorkspace(workspaceDir: string, opts: AttachWorkspaceOptio
const existing = readReceipt(gbrainHomeDir);
const sameWorkspace = existing !== null && realpathOrResolve(existing.workspace_dir) === resolvedWs;
const receipt: InstallReceipt = {
// Record repo_url from the adopted origin so the no-daemon push gate
// (repoPhaseComplete) recognizes the repo phase as done on this machine —
// otherwise the per-turn/session-end pushes defer FOREVER after an attach
// (the ONLY install path in a cloud sandbox, where `bootstrap repo` is
// refused). This does NOT bypass the privacy gate: workspacePush still runs
// the visibility ladder at push time and fails closed on a public/
// unverifiable origin. Preserve an existing repo_url on a same-workspace
// re-attach; else adopt the current github origin.
const existingRepoUrl = sameWorkspace ? (existing as RepoReceipt).repo_url : undefined;
const originUrl = gitOriginUrl(resolvedWs);
// Record the origin as repo_url whenever one exists — github or self-hosted.
// repoPhaseComplete binds by owner/name for github and by exact URL otherwise;
// either way workspacePush still runs the visibility ladder at push time, so a
// non-verifiable origin stays fail-closed (refused unless the operator sets the
// escape hatch). Recording it only clears the "repo phase ran" gate.
const adoptedRepoUrl = existingRepoUrl ?? (originUrl ?? undefined);
const receipt: RepoReceipt = {
receipt_version: 1,
workspace_dir: resolvedWs,
source_id: manifest.source_id,
@@ -111,6 +129,7 @@ export function attachWorkspace(workspaceDir: string, opts: AttachWorkspaceOptio
brain_created_by_bootstrap: sameWorkspace ? existing.brain_created_by_bootstrap : false,
created_paths: sameWorkspace ? existing.created_paths : [],
registrations: sameWorkspace ? existing.registrations : [],
...(adoptedRepoUrl ? { repo_url: adoptedRepoUrl } : {}),
};
// writeReceipt assumes the bootstrap/ subdir exists; attach runs on a fresh
// machine where nothing has created it yet.
+287
View File
@@ -0,0 +1,287 @@
/**
* codex-toml.ts managed marker-block writer for Codex's config.toml
* (#4043, the fired CX2-17 revisit; see TARGETS['codex-2026-08'] in
* host-specs.ts for the verified format assumptions).
*
* Why a direct writer exists at all: `codex mcp add` cannot express an
* inline `bearer_token` (only `--bearer-token-env-var`, which reintroduces
* the shell-profile dependency framework-spawned codex lacks), so the
* harness lane owns exactly one `[mcp_servers.<name>]` table between two
* full-line comment markers. Everything outside the markers survives
* byte-for-byte.
*
* Safety invariants [C10 + adversarial-verify corrections]:
* - Foreign-server detection parses the file (Bun.TOML.parse) with our block
* stripped a header-only regex false-negatives inline tables, dotted
* keys, and quoted headers, and a duplicate table definition is a HARD
* TOML parse error that bricks codex outright.
* - Rewrites REMOVE the old block and RE-ANCHOR at EOF (refusing on trailing
* content would let any later legitimate codex write permanently block
* update/removal; at EOF our table can never absorb someone else's keys).
* - The rendered output is parse-validated BEFORE rename, and our table's
* keys are asserted to be exactly what we wrote; on any failure the
* original file is kept.
* - Exactly one begin/end pair, in order, full-line match anything else is
* a hand-edit; refuse rather than guess.
* - Secrets hygiene: tmp file created 0600 with a random suffix, the target
* is chmod'd 0600 (it carries a bearer token), and the .bak is chmod'd
* 0600 (on re-runs it carries the PREVIOUS token).
* - CRLF configs are scanned with `\r` stripped and re-emitted with their
* dominant EOL; a missing trailing newline is repaired before append so a
* marker can never glue onto the last line (the run-2 duplicate-append
* brick).
*/
import { randomBytes } from 'node:crypto';
import {
chmodSync,
copyFileSync,
existsSync,
mkdirSync,
readFileSync,
realpathSync,
renameSync,
statSync,
writeFileSync,
} from 'node:fs';
import { dirname } from 'node:path';
import { CODEX_TOML_BLOCK_BEGIN, CODEX_TOML_BLOCK_END } from './host-specs.ts';
export interface CodexHttpServerBlock {
/** MCP server name — bare-key charset only (goes into a table header). */
name: string;
/** Streamable-HTTP MCP endpoint url (normalized upstream). */
url: string;
/** Bearer token written inline (the whole point of the direct writer). */
bearerToken: string;
}
export interface WriteCodexBlockResult {
configPath: string;
/** True when a prior managed block was replaced (idempotent re-run). */
replacedPrior: boolean;
backupPath: string | null;
notes: string[];
}
export interface RemoveCodexBlockResult {
configPath: string;
removed: boolean;
backupPath: string | null;
notes: string[];
}
/** Escape a value for a TOML basic string. */
export function tomlString(value: string): string {
if (/[\n\r\0\t]/.test(value)) {
throw new Error('control characters are not allowed in codex config values');
}
return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
}
function assertBareKeyName(name: string): void {
if (!/^[A-Za-z0-9_-]+$/.test(name)) {
throw new Error(
`MCP server name "${name}" is not a bare TOML key ([A-Za-z0-9_-]+) — pick a simpler --name`,
);
}
}
interface BlockSpan {
/** Line index of the begin marker, or -1 when absent. */
begin: number;
/** Line index of the end marker, or -1 when absent. */
end: number;
}
/**
* Locate the managed block in \n-normalized lines. Throws on marker
* anomalies (duplicates, out of order, one without the other) those are
* hand-edits we must not guess through.
*/
function findBlock(lines: string[]): BlockSpan {
const begins: number[] = [];
const ends: number[] = [];
lines.forEach((line, i) => {
if (line === CODEX_TOML_BLOCK_BEGIN) begins.push(i);
if (line === CODEX_TOML_BLOCK_END) ends.push(i);
});
if (begins.length === 0 && ends.length === 0) return { begin: -1, end: -1 };
if (begins.length !== 1 || ends.length !== 1 || begins[0] > ends[0]) {
throw new Error(
`the gbrain-managed block markers in this config.toml are damaged ` +
`(${begins.length} begin / ${ends.length} end${begins.length === 1 && ends.length === 1 ? ', out of order' : ''}) — ` +
`the file was hand-edited inside the managed region. Fix the markers (or delete the whole block) and re-run.`,
);
}
return { begin: begins[0], end: ends[0] };
}
function stripBlock(lines: string[]): { remainder: string[]; hadBlock: boolean } {
const span = findBlock(lines);
if (span.begin === -1) return { remainder: lines, hadBlock: false };
const remainder = [...lines.slice(0, span.begin), ...lines.slice(span.end + 1)];
return { remainder, hadBlock: true };
}
function parseToml(text: string): Record<string, unknown> {
// Bun.TOML is available in this runtime (no dependency added).
const parsed = (Bun as unknown as { TOML: { parse(t: string): unknown } }).TOML.parse(text);
if (typeof parsed !== 'object' || parsed === null) {
throw new Error('config.toml did not parse to a table');
}
return parsed as Record<string, unknown>;
}
/**
* True when `[mcp_servers.<name>]` is defined OUTSIDE our managed block, in
* ANY toml spelling (header, inline table, dotted key, quoted header,
* array-of-tables). Throws when the surrounding config does not parse
* appending to a broken file could only deepen the damage.
*/
export function detectForeignCodexServer(configText: string, name: string): boolean {
const lines = configText.replace(/\r\n/g, '\n').split('\n');
const { remainder } = stripBlock(lines);
const parsed = parseToml(remainder.join('\n'));
const servers = parsed.mcp_servers;
if (typeof servers !== 'object' || servers === null) return false;
return (servers as Record<string, unknown>)[name] !== undefined;
}
function renderBlock(block: CodexHttpServerBlock): string[] {
return [
CODEX_TOML_BLOCK_BEGIN,
`[mcp_servers.${block.name}]`,
`url = ${tomlString(block.url)}`,
`bearer_token = ${tomlString(block.bearerToken)}`,
CODEX_TOML_BLOCK_END,
];
}
/** Atomic 0600 write preserving symlinks and the file's dominant EOL. */
function atomicWriteToml(configPath: string, unixText: string, crlf: boolean): void {
const target = existsSync(configPath) ? realpathSync(configPath) : configPath;
mkdirSync(dirname(target), { recursive: true });
const tmp = `${target}.tmp-${randomBytes(6).toString('hex')}`;
const out = crlf ? unixText.replace(/\n/g, '\r\n') : unixText;
writeFileSync(tmp, out, { encoding: 'utf8', mode: 0o600 });
chmodSync(tmp, 0o600);
renameSync(tmp, target);
}
/**
* Idempotently write the managed `[mcp_servers.<name>]` block: strip any
* prior managed block, re-anchor the fresh one at EOF, validate the render,
* then swap atomically. The token lands inline, so the file is forced 0600.
*/
export function writeCodexHttpServerBlock(
configPath: string,
block: CodexHttpServerBlock,
): WriteCodexBlockResult {
assertBareKeyName(block.name);
const notes: string[] = [];
let rawText = '';
let existed = false;
if (existsSync(configPath)) {
existed = true;
rawText = readFileSync(configPath, 'utf8');
}
const crlf = rawText.includes('\r\n');
const lines = rawText.replace(/\r\n/g, '\n').split('\n');
const { remainder, hadBlock } = stripBlock(lines);
// Foreign ownership guard — a duplicate table definition is a hard TOML
// parse error that would brick codex entirely.
const remainderText = remainder.join('\n');
let parsedRemainder: Record<string, unknown>;
try {
parsedRemainder = parseToml(remainderText);
} catch (e) {
throw new Error(
`${configPath} does not parse as TOML outside the gbrain-managed block ` +
`(${(e as Error).message}) — fix the config (codex itself cannot read it either) and re-run.`,
);
}
const servers = parsedRemainder.mcp_servers;
if (typeof servers === 'object' && servers !== null && (servers as Record<string, unknown>)[block.name] !== undefined) {
throw new Error(
`[mcp_servers.${block.name}] is already defined in ${configPath} outside the gbrain-managed block — ` +
`refusing to double-define it (that is a hard TOML parse error). ` +
`Remove the existing entry (codex mcp remove ${block.name}) or pick another name (--name).`,
);
}
// Assemble: remainder (trailing newline repaired, trailing blank run
// collapsed to one separator line) + block at EOF.
const trimmed = [...remainder];
while (trimmed.length > 0 && trimmed[trimmed.length - 1].trim() === '') trimmed.pop();
const body = trimmed.length > 0 ? [...trimmed, ''] : [];
const nextLines = [...body, ...renderBlock(block), ''];
const nextText = nextLines.join('\n');
// Post-render validation: parse + assert OUR table's keys are exactly ours.
const rendered = parseToml(nextText);
const renderedServers = rendered.mcp_servers as Record<string, unknown> | undefined;
const ours = renderedServers?.[block.name];
const ourKeys = typeof ours === 'object' && ours !== null ? Object.keys(ours as object).sort() : [];
if (ourKeys.join(',') !== 'bearer_token,url') {
throw new Error(
`post-render validation failed: [mcp_servers.${block.name}] keys are [${ourKeys.join(', ')}], ` +
`expected exactly [bearer_token, url] — original file left untouched.`,
);
}
let backupPath: string | null = null;
if (existed) {
backupPath = `${configPath}.bak`;
copyFileSync(configPath, backupPath);
chmodSync(backupPath, 0o600); // on re-runs the .bak carries the previous token
const mode = statSync(configPath).mode & 0o777;
if ((mode & 0o077) !== 0) {
notes.push(
`${configPath} was group/other-readable (mode ${mode.toString(8)}); tightened to 0600 — it now carries a bearer token.`,
);
}
}
atomicWriteToml(configPath, nextText, crlf);
return { configPath, replacedPrior: hadBlock, backupPath, notes };
}
/**
* Remove the managed block (marker-keyed; content outside survives
* byte-for-byte). Absent file / absent block are calm no-ops. Damaged
* markers refuse via findBlock removal never guesses.
*/
export function removeCodexHttpServerBlock(
configPath: string,
name: string,
): RemoveCodexBlockResult {
assertBareKeyName(name);
const notes: string[] = [];
if (!existsSync(configPath)) {
return { configPath, removed: false, backupPath: null, notes: ['no config.toml — nothing to remove'] };
}
const rawText = readFileSync(configPath, 'utf8');
const crlf = rawText.includes('\r\n');
const lines = rawText.replace(/\r\n/g, '\n').split('\n');
const { remainder, hadBlock } = stripBlock(lines);
if (!hadBlock) {
return { configPath, removed: false, backupPath: null, notes: ['no gbrain-managed block — nothing to remove'] };
}
// Validate what we are about to write back — never leave codex unreadable.
const trimmed = [...remainder];
while (trimmed.length > 0 && trimmed[trimmed.length - 1].trim() === '') trimmed.pop();
const nextText = trimmed.length > 0 ? `${trimmed.join('\n')}\n` : '';
if (nextText !== '') parseToml(nextText);
const backupPath = `${configPath}.bak`;
copyFileSync(configPath, backupPath);
chmodSync(backupPath, 0o600);
atomicWriteToml(configPath, nextText, crlf);
return { configPath, removed: true, backupPath, notes };
}
+142 -2
View File
@@ -19,8 +19,8 @@
* manifest but not the receipt.
*/
import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
export const FORMAT_VERSION = 1;
export const AGENT_MANIFEST_FILENAME = 'agent.json';
@@ -202,3 +202,143 @@ export function writeReceipt(gbrainHomeDir: string, receipt: InstallReceipt): vo
writeFileSync(tmp, `${JSON.stringify(receipt, null, 2)}\n`, 'utf8');
renameSync(tmp, path);
}
// ---------------------------------------------------------------------------
// Machine-level harness receipt (#4043 `gbrain bootstrap harness`)
//
// A SEPARATE file from receipt.json on purpose: the install receipt is
// workspace-keyed (workspace_dir/source_id/agent_name are required — a
// harness-only box has none of those), and the two lifecycles differ
// (workspace uninstall vs harness --remove). Same CX2-12 discipline: typed
// read states, newer-format refusal, broken-file backup-aside, atomic write.
//
// WRITE-AHEAD contract [F1/C7]: the apply path persists this receipt right
// after minting — every planned target starts `pending` and flips to
// `confirmed` (or records its failure) as the wiring lands, and
// `token.previous_ids` carries every not-yet-revoked prior token until rotation
// completes. A crash at any step leaves a receipt --remove can consume.
// ---------------------------------------------------------------------------
export type HarnessTargetKind = 'mcp' | 'permission' | 'hooks';
export type HarnessTargetState = 'pending' | 'confirmed' | 'failed';
export interface HarnessTarget {
host: 'claude-code' | 'codex';
kind: HarnessTargetKind;
state: HarnessTargetState;
/** user scope or a --project dir (hooks); user for mcp/permission. */
scope: string;
/** Settings/config file the target writes (absent for CLI-mediated mcp). */
path?: string;
/** MCP server name (mcp targets). */
name?: string;
/** permissions.allow entry (permission targets). */
entry?: string;
/** Hook marker value (hooks targets). */
marker?: string;
/** Write mechanism note, e.g. 'toml-block' vs 'claude-cli'. */
mechanism?: string;
/** One-line failure reason when state === 'failed'. */
error?: string;
}
export interface HarnessReceipt {
harness_receipt_version: 1;
created_at: string;
created_by: string;
/** Normalized MCP endpoint the box is wired to. */
url: string;
/** Engine reported by /health at apply time (postgres → degraded per-turn). */
engine?: string;
/** Serve version at apply time — --status re-checks for skew [F7]. */
serve_version?: string;
source_id: string;
token: {
name: string;
/** Row id — the only safe revocation key. */
id?: string;
/** False when --token supplied a pre-minted token (never revoke those). */
minted: boolean;
/**
* EVERY prior minted-token id still awaiting revocation (mint-first
* rotation [C7]). An array, not a slot [X4]: a failed rotation must not
* forget the token before last, and a --token re-run must keep carrying
* ids minted by earlier runs until they are revoked.
*/
previous_ids?: string[];
};
targets: HarnessTarget[];
}
export function harnessReceiptPath(gbrainHomeDir: string): string {
return join(gbrainHomeDir, 'bootstrap', 'harness.json');
}
export type HarnessReceiptReadState =
| { state: 'absent' }
| { state: 'ok'; receipt: HarnessReceipt }
| { state: 'newer'; receiptVersion: number }
| { state: 'invalid' };
export function readHarnessReceiptState(gbrainHomeDir: string): HarnessReceiptReadState {
const path = harnessReceiptPath(gbrainHomeDir);
if (!existsSync(path)) return { state: 'absent' };
try {
const parsed = JSON.parse(readFileSync(path, 'utf8')) as HarnessReceipt;
if (parsed.harness_receipt_version === 1) {
// Version alone is not enough: every consumer dereferences targets[]
// and token.name unchecked, so a hand-damaged version-1 receipt must
// take the designed 'invalid' → backup-aside path, not a TypeError.
const shapeOk =
Array.isArray(parsed.targets) &&
typeof parsed.token === 'object' &&
parsed.token !== null &&
typeof parsed.token.name === 'string' &&
typeof parsed.url === 'string';
return shapeOk ? { state: 'ok', receipt: parsed } : { state: 'invalid' };
}
if (typeof parsed.harness_receipt_version === 'number' && parsed.harness_receipt_version > 1) {
return { state: 'newer', receiptVersion: parsed.harness_receipt_version };
}
return { state: 'invalid' };
} catch {
return { state: 'invalid' };
}
}
/**
* Pre-write guard (mirror of guardReceiptOverwrite): newer-format refuses
* with an upgrade-first error; an unreadable receipt is backed up loudly so
* the recorded targets remain recoverable.
*/
export function guardHarnessReceiptOverwrite(gbrainHomeDir: string): { brokenBackupPath?: string } {
const state = readHarnessReceiptState(gbrainHomeDir);
if (state.state === 'newer') {
throw new Error(
`the harness receipt at ${harnessReceiptPath(gbrainHomeDir)} was written by a newer gbrain ` +
`(harness_receipt_version ${state.receiptVersion}) — upgrade gbrain before re-running bootstrap harness.`,
);
}
if (state.state === 'invalid') {
const path = harnessReceiptPath(gbrainHomeDir);
const backup = `${path}.broken-${Date.now()}`;
renameSync(path, backup);
return { brokenBackupPath: backup };
}
return {};
}
/** Atomic write, 0600 (records wiring topology + token ids, not plaintext). */
export function writeHarnessReceipt(gbrainHomeDir: string, receipt: HarnessReceipt): void {
const path = harnessReceiptPath(gbrainHomeDir);
mkdirSync(dirname(path), { recursive: true });
const tmp = `${path}.tmp-${process.pid}`;
writeFileSync(tmp, `${JSON.stringify(receipt, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
renameSync(tmp, path);
}
/** Consume (delete) the harness receipt after a fully successful --remove. */
export function deleteHarnessReceipt(gbrainHomeDir: string): void {
const path = harnessReceiptPath(gbrainHomeDir);
if (existsSync(path)) rmSync(path);
}
File diff suppressed because it is too large Load Diff
+495 -43
View File
@@ -10,8 +10,9 @@
* and dedupe match on the marker (surviving reordering and command-string
* drift), and foreign hooks / permissions / every other settings key are
* never touched. Writes are atomic (tmp + rename) with a `.bak` of the
* previous file; a parse-broken existing file is backed up aside and the
* write starts clean with a loud note in the result [G5].
* previous file; a parse-broken existing file ABORTS the write with
* fix-and-re-run instructions (fail-closed, matching removal's stance a
* rewrite could drop permissions/allowlist entries gbrain cannot parse) [G5].
*
* MCP registration helpers BUILD ARGV ONLY the bootstrap dispatcher execs
* them (and records the registration in the install receipt). Precedent:
@@ -23,16 +24,21 @@
* (and GBRAIN_HOME when isolated) ride the registration itself.
*/
import { randomBytes } from 'node:crypto';
import {
chmodSync,
copyFileSync,
existsSync,
mkdirSync,
readFileSync,
realpathSync,
renameSync,
statSync,
writeFileSync,
} from 'node:fs';
import { dirname, isAbsolute, join } from 'node:path';
import {
CLAUDE_COMMITTED_SETTINGS_FILE_RELPATH,
CLAUDE_HOOK_DEFAULT_TIMEOUT_SECS,
CLAUDE_HOOK_EVENTS,
CLAUDE_HOOK_SUBCOMMAND,
@@ -50,6 +56,12 @@ export interface ClaudeHookEnv {
GBRAIN_SOURCE: string;
/** Set only for --isolated installs (PARENT dir; config appends `.gbrain`). */
GBRAIN_HOME?: string;
/**
* 'harness' on #4043 harness-mode wiring: `gbrain hook` yields when the
* lane is harness AND the cwd carries a workspace bootstrap install, so
* the same event never fires twice (Claude Code merges settings scopes).
*/
GBRAIN_HOOK_LANE?: string;
}
export interface WriteClaudeHooksOpts {
@@ -58,8 +70,40 @@ export interface WriteClaudeHooksOpts {
env: ClaudeHookEnv;
/** Per-event timeout override (SECONDS — the settings-file unit). */
timeoutSecs?: Partial<Record<ClaudeHookEvent, number>>;
/** Subset of events to wire; default all four. */
/** Subset of events to wire; default every event in CLAUDE_HOOK_EVENTS. */
events?: ClaudeHookEvent[];
/**
* Marker VALUE stamped on (and stripped from) our entries. Default is the
* workspace-install marker; harness mode passes GBRAIN_HARNESS_MARKER_VALUE
* so the two installs coexist and each removal strips only its own.
*/
marker?: string;
/**
* [D12] Events already owned by the COMMITTED settings carrier the writer
* strips stale local copies of these but re-adds nothing for them, so one
* event never fires from both files. The workspace wrapper derives it from
* committedHookEvents(ws); path-parameterized callers pass their own.
*/
carriedEvents?: Set<ClaudeHookEvent>;
/**
* Backup strategy for the pre-write copy. 'fixed' (default) keeps the
* historical `.bak` sibling; 'timestamped' avoids the shared-slot problem
* when two writers touch the same file in one transaction.
*/
backupStrategy?: 'fixed' | 'timestamped';
/**
* Refuse when a gbrain entry with a DIFFERENT marker already wires one of
* our target events in this file (harness lane: a workspace install or a
* differently-scoped harness install owns it double-wiring would fire the
* same hook twice per event) [C6].
*/
refuseOnForeignGbrainMarker?: boolean;
/**
* Mode for a FRESHLY-CREATED settings file (existing files keep their mode
* via the atomic writer). Harness user-scope writes pass 0o600 to match
* Claude Code's own convention for that file [X11].
*/
freshMode?: number;
}
export interface WriteClaudeHooksResult {
@@ -69,7 +113,9 @@ export interface WriteClaudeHooksResult {
removedPrior: number;
/** `.bak` of the pre-write file (null when no file existed). */
backupPath: string | null;
/** Where a parse-broken original was moved (null when parse succeeded). */
/** Always null since the fail-closed change (a parse-broken file now
* aborts the write instead of being moved aside). Kept for result-shape
* stability. */
brokenBackupPath: string | null;
notes: string[];
}
@@ -121,24 +167,103 @@ export function buildClaudeHookCommand(
): string {
const assignments: string[] = [`GBRAIN_SOURCE=${env.GBRAIN_SOURCE}`];
if (env.GBRAIN_HOME) assignments.push(`GBRAIN_HOME=${env.GBRAIN_HOME}`);
if (env.GBRAIN_HOOK_LANE) assignments.push(`GBRAIN_HOOK_LANE=${env.GBRAIN_HOOK_LANE}`);
const parts = ['env', ...assignments, gbrainBin, 'hook', CLAUDE_HOOK_SUBCOMMAND[event]];
return parts.map(shellQuote).join(' ');
}
function isOurs(entry: unknown): boolean {
export function claudeCommittedSettingsPath(workspaceDir: string): string {
return join(workspaceDir, CLAUDE_COMMITTED_SETTINGS_FILE_RELPATH);
}
/**
* The COMMITTED carrier's command [D12]: PATH-resolved and fail-open. No
* absolute binary path the committed file travels between machines and
* cloud sessions; wherever gbrain is not installed the hook exits 0 silently
* instead of erroring every turn.
*/
export function buildPortableClaudeHookCommand(event: ClaudeHookEvent, env: ClaudeHookEnv): string {
const assignments: string[] = [`GBRAIN_SOURCE=${env.GBRAIN_SOURCE}`];
if (env.GBRAIN_HOME) assignments.push(`GBRAIN_HOME=${env.GBRAIN_HOME}`);
const invoke = ['env', ...assignments, 'gbrain', 'hook', CLAUDE_HOOK_SUBCOMMAND[event]]
.map(shellQuote)
.join(' ');
return `command -v gbrain >/dev/null 2>&1 && ${invoke} || exit 0`;
}
/** Events the COMMITTED settings file already carries with our marker [D12]
* the local writer skips these so one event never fires from both files. */
/** Pull the GBRAIN_SOURCE value out of a rendered portable hook command so the
* exact-match check is agnostic to the (operator-chosen) source id. Returns
* null when the command isn't shaped like ours. */
function extractHookSource(command: string): string | null {
const m = /command -v gbrain >\/dev\/null 2>&1 && env GBRAIN_SOURCE=('[^']*'|[^ ]+) gbrain hook /.exec(command);
if (!m) return null;
const raw = m[1]!;
return raw.startsWith("'") ? raw.slice(1, -1).replace(/'\\''/g, "'") : raw;
}
export function committedHookEvents(workspaceDir: string): Set<ClaudeHookEvent> {
const carried = new Set<ClaudeHookEvent>();
try {
const raw = readFileSync(claudeCommittedSettingsPath(workspaceDir), 'utf8');
const parsed = JSON.parse(raw) as { hooks?: Record<string, unknown> };
const hooks = parsed?.hooks;
if (typeof hooks !== 'object' || hooks === null) return carried;
for (const event of CLAUDE_HOOK_EVENTS) {
const groups = (hooks as Record<string, unknown>)[event];
if (!Array.isArray(groups)) continue;
const ours = groups.some(
(g) =>
typeof g === 'object' && g !== null &&
Array.isArray((g as HookMatcherGroup).hooks) &&
((g as HookMatcherGroup).hooks as unknown[]).some(
(h) =>
isOurs(h) &&
// A committed file is repo-contributor-writable: a marker + a
// bare `includes('gbrain hook')` substring is spoofable
// (`evil; # gbrain hook` suppresses the real local install AND
// runs attacker code). Require the EXACT portable-command shape
// this event would render — the anchored `command -v gbrain …`
// guard + `|| exit 0` structure a foreign command can't fake.
typeof (h as HookCommandEntry).command === 'string' &&
(h as HookCommandEntry).command === buildPortableClaudeHookCommand(event, {
GBRAIN_SOURCE: extractHookSource((h as HookCommandEntry).command as string) ?? '',
}),
),
);
if (ours) carried.add(event);
}
} catch {
/* absent/corrupt committed file → nothing carried */
}
return carried;
}
function isOurs(entry: unknown, marker: string = GBRAIN_HOOK_MARKER_VALUE): boolean {
return (
typeof entry === 'object' &&
entry !== null &&
(entry as Record<string, unknown>)[GBRAIN_HOOK_MARKER_KEY] === GBRAIN_HOOK_MARKER_VALUE
(entry as Record<string, unknown>)[GBRAIN_HOOK_MARKER_KEY] === marker
);
}
/** True when the entry carries the gbrain marker KEY with any OTHER value. */
function isForeignGbrainMarked(entry: unknown, marker: string): boolean {
if (typeof entry !== 'object' || entry === null) return false;
const v = (entry as Record<string, unknown>)[GBRAIN_HOOK_MARKER_KEY];
return typeof v === 'string' && v !== marker;
}
/**
* Strip marker-carrying command entries from one event's matcher-group array.
* Groups EMPTIED by the removal are dropped; groups that were already empty
* (foreign) survive untouched. Returns the surviving groups + removal count.
* The group-drop rule is marker-independent: it fires only when THIS call's
* filter emptied a previously non-empty group, so it can never drop a group a
* different marker still owns.
*/
function stripOurEntries(groups: unknown[]): { kept: unknown[]; removed: number } {
function stripOurEntries(groups: unknown[], marker: string = GBRAIN_HOOK_MARKER_VALUE): { kept: unknown[]; removed: number } {
const kept: unknown[] = [];
let removed = 0;
for (const group of groups) {
@@ -148,7 +273,7 @@ function stripOurEntries(groups: unknown[]): { kept: unknown[]; removed: number
}
const g = group as HookMatcherGroup;
const before = g.hooks!.length;
const filtered = g.hooks!.filter((h) => !isOurs(h));
const filtered = g.hooks!.filter((h) => !isOurs(h, marker));
removed += before - filtered.length;
if (filtered.length === 0 && before > 0 && filtered.length !== before) {
continue; // we emptied it → drop the husk
@@ -162,12 +287,33 @@ function stripOurEntries(groups: unknown[]): { kept: unknown[]; removed: number
return { kept, removed };
}
/** Atomic write (tmp + rename), creating parent dirs. */
function atomicWriteJson(path: string, value: unknown): void {
mkdirSync(dirname(path), { recursive: true });
const tmp = `${path}.tmp-${process.pid}`;
writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
renameSync(tmp, path);
/**
* Atomic write (tmp + rename), creating parent dirs. Hardened for shared
* user-scope targets [C10]: the SYMLINK TARGET is resolved first so a
* dotfile-manager-linked settings file survives as a link (a bare rename
* would replace the link with a regular file); the tmp file uses a random
* suffix and inherits the existing file's mode (a 0600 file stays 0600
* the pid-suffixed umask-default tmp was fine for gitignored workspace files
* but not for user-global config).
*/
function atomicWriteJson(path: string, value: unknown, freshMode?: number): void {
const target = existsSync(path) ? realpathSync(path) : path;
mkdirSync(dirname(target), { recursive: true });
let mode: number | undefined;
try {
mode = statSync(target).mode & 0o777;
} catch {
mode = freshMode; // fresh file: caller's convention (user-scope → 0600) [X11]
}
const tmp = `${target}.tmp-${randomBytes(6).toString('hex')}`;
writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', ...(mode !== undefined ? { mode } : {}) });
if (mode !== undefined) chmodSync(tmp, mode); // writeFileSync mode applies only on create
renameSync(tmp, target);
}
/** Pre-write backup path per strategy; timestamped avoids the shared-slot loss. */
function backupPathFor(settingsPath: string, strategy: 'fixed' | 'timestamped'): string {
return strategy === 'timestamped' ? `${settingsPath}.bak-${Date.now()}` : `${settingsPath}.bak`;
}
interface LoadedSettings {
@@ -178,10 +324,13 @@ interface LoadedSettings {
}
/**
* Parse the existing settings file. Absent/empty `{}`. Parse error the
* broken file is MOVED to a timestamped `.broken-*` backup and the caller
* starts clean, with a loud note (the user's broken-by-hand file is never
* silently destroyed, and never silently half-merged) [G5].
* Parse the existing settings file. Absent/empty `{}`. Parse error
* THROW, fail-closed [G5]: the file may carry permissions/allowlist entries
* gbrain cannot see, so replacing it with a fresh file (the old behavior
* backup + start clean) silently dropped the user's live settings. Removal
* (`removeClaudeHooks`) already refuses to touch what it cannot parse; the
* write path now matches that stance. The user fixes the JSON, re-runs, and
* the structural merge preserves everything.
*/
function loadSettings(path: string): LoadedSettings {
const notes: string[] = [];
@@ -204,27 +353,27 @@ function loadSettings(path: string): LoadedSettings {
}
return { settings: parsed as SettingsObject, existed: true, brokenBackupPath: null, notes };
} catch (e) {
const broken = `${path}.broken-${Date.now()}`;
copyFileSync(path, broken);
notes.push(
`WARNING: ${path} was not valid JSON (${(e as Error).message}); ` +
`the original was backed up to ${broken} and hooks were written to a fresh file. ` +
`Restore any hand-made settings from the backup.`,
throw new Error(
`${path} is not valid JSON (${(e as Error).message}) — refusing to rewrite a settings file ` +
`gbrain cannot parse (it may carry your permissions/allowlist entries). Fix the JSON by ` +
`hand, then re-run \`gbrain bootstrap hooks --harness claude-code --repair\` ` +
`(the structural merge preserves your settings).`,
);
return { settings: {}, existed: true, brokenBackupPath: broken, notes };
}
}
// ── Writers [G5, CX2-17] ────────────────────────────────────────────────────
/**
* Structural-merge gbrain's hook entries into `<ws>/.claude/settings.local.json`.
* Idempotent: prior marker-carrying entries are removed before the fresh set
* is appended (run twice one entry per event). Foreign hooks, permissions,
* and every other key survive byte-for-byte at the structural level.
* Structural-merge gbrain's hook entries into an EXPLICIT settings file
* (workspace settings.local.json or user-scope ~/.claude/settings.json).
* Idempotent: prior same-marker entries are removed before the fresh set is
* appended (run twice one entry per event). Foreign hooks, other-marker
* gbrain entries, permissions, and every other key survive byte-for-byte at
* the structural level.
*/
export function writeClaudeHooks(
workspaceDir: string,
export function writeClaudeHooksAt(
settingsPath: string,
opts: WriteClaudeHooksOpts,
): WriteClaudeHooksResult {
if (!isAbsolute(opts.gbrainBin)) {
@@ -235,8 +384,9 @@ export function writeClaudeHooks(
throw new Error(`env ${k} contains control characters — refusing to embed in a hook command`);
}
}
const marker = opts.marker ?? GBRAIN_HOOK_MARKER_VALUE;
const backupStrategy = opts.backupStrategy ?? 'fixed';
const settingsPath = claudeSettingsPath(workspaceDir);
const { settings, existed, brokenBackupPath, notes } = loadSettings(settingsPath);
// hooks key: merge into an object; a structurally-foreign value is backed
@@ -253,9 +403,57 @@ export function writeClaudeHooks(
}
const events = opts.events ?? [...CLAUDE_HOOK_EVENTS];
// [C6] Same command double-fire guard: refuse when a gbrain entry carrying a
// DIFFERENT marker already wires one of our target events in this file —
// Claude Code would run both.
if (opts.refuseOnForeignGbrainMarker) {
for (const event of events) {
const groups = hooks[event];
if (!Array.isArray(groups)) continue;
for (const group of groups) {
const g = group as HookMatcherGroup;
if (!Array.isArray(g?.hooks)) continue;
for (const entry of g.hooks) {
if (isForeignGbrainMarked(entry, marker)) {
const foreign = (entry as Record<string, unknown>)[GBRAIN_HOOK_MARKER_KEY];
throw new Error(
`${settingsPath} already wires hooks.${event} under gbrain marker "${String(foreign)}" — ` +
`refusing to double-wire the same hook (both entries would fire every event). ` +
`Remove the other install first (gbrain bootstrap harness --remove, or gbrain bootstrap uninstall).`,
);
}
}
}
}
}
// [D12] Dedupe invariant: an event carried by the COMMITTED settings file
// never also fires from the local file. The caller supplies the carried set
// (the workspace wrapper reads it from committedHookEvents(ws); the harness
// --project lane does the same for its dirs) — this path-parameterized
// writer has no workspace to derive it from.
const carried = opts.carriedEvents ?? new Set<ClaudeHookEvent>();
let removedPrior = 0;
const installed: Array<{ event: ClaudeHookEvent; command: string }> = [];
// [X3] Convergence: strip OUR marker from EVERY event in the file first —
// not just the requested subset — so a re-run with fewer events (e.g.
// --no-capture dropping Stop/SessionEnd) removes the ones no longer wanted
// instead of leaving them live. Foreign and other-marker entries survive.
for (const event of Object.keys(hooks)) {
const groups = hooks[event];
if (!Array.isArray(groups)) continue; // structurally foreign — never touch
const { kept, removed } = stripOurEntries(groups, marker);
removedPrior += removed;
if (removed === 0) continue;
if (kept.length === 0) {
delete hooks[event]; // emptied by OUR removal — drop the key
} else {
hooks[event] = kept;
}
}
for (const event of events) {
let groups = hooks[event];
if (!Array.isArray(groups)) {
@@ -266,11 +464,105 @@ export function writeClaudeHooks(
}
groups = [];
}
const { kept, removed } = stripOurEntries(groups as unknown[]);
removedPrior += removed;
const kept = [...(groups as unknown[])];
if (carried.has(event)) {
notes.push(`${event}: carried by the committed .claude/settings.json — local entry skipped [D12]`);
if (kept.length === 0) delete hooks[event];
else hooks[event] = kept;
continue;
}
const command = buildClaudeHookCommand(opts.gbrainBin, event, opts.env);
const timeout = opts.timeoutSecs?.[event] ?? CLAUDE_HOOK_DEFAULT_TIMEOUT_SECS[event];
const entry: HookCommandEntry = {
type: 'command',
command,
timeout,
[GBRAIN_HOOK_MARKER_KEY]: marker,
};
kept.push({ hooks: [entry] });
hooks[event] = kept;
installed.push({ event, command });
}
settings.hooks = hooks;
let backupPath: string | null = null;
if (existed && brokenBackupPath === null) {
backupPath = backupPathFor(settingsPath, backupStrategy);
copyFileSync(settingsPath, backupPath);
}
atomicWriteJson(settingsPath, settings, opts.freshMode);
return { settingsPath, installed, removedPrior, backupPath, brokenBackupPath, notes };
}
/**
* Workspace-lane wrapper (historical signature: `<ws>/.claude/settings.local.json`,
* bootstrap-v1 marker, fixed `.bak`). Supplies the [D12] carried-events set so
* an event owned by the committed carrier never also fires locally.
*/
export function writeClaudeHooks(
workspaceDir: string,
opts: WriteClaudeHooksOpts,
): WriteClaudeHooksResult {
return writeClaudeHooksAt(claudeSettingsPath(workspaceDir), {
carriedEvents: committedHookEvents(workspaceDir),
...opts,
});
}
/**
* Write hooks into the COMMITTED `.claude/settings.json` [D12] the only
* carrier that survives into fresh cloud clones (hooks are snapshotted at
* session start; the gitignored local file never exists there). Commands are
* PATH-resolved and fail-open (buildPortableClaudeHookCommand). After the
* committed write, the same events are stripped from the LOCAL file so an
* event never fires from both carriers.
*/
export function writeCommittedClaudeHooks(
workspaceDir: string,
opts: { env: ClaudeHookEnv; events?: ClaudeHookEvent[]; timeoutSecs?: Partial<Record<ClaudeHookEvent, number>> },
): WriteClaudeHooksResult {
if (opts.env.GBRAIN_HOME) {
throw new Error(
'GBRAIN_HOME is machine-specific and must not be embedded in the COMMITTED hook carrier ' +
'(the file travels between machines) — isolated installs stay on the local carrier',
);
}
for (const [k, v] of Object.entries(opts.env)) {
if (typeof v === 'string' && /[\n\r\0]/.test(v)) {
throw new Error(`env ${k} contains control characters — refusing to embed in a hook command`);
}
}
const settingsPath = claudeCommittedSettingsPath(workspaceDir);
const { settings, existed, brokenBackupPath, notes } = loadSettings(settingsPath);
let hooks = settings.hooks as Record<string, unknown> | undefined;
if (typeof hooks !== 'object' || hooks === null || Array.isArray(hooks)) {
if (hooks !== undefined) {
notes.push(
`WARNING: existing "hooks" key was not an object (${JSON.stringify(hooks).slice(0, 80)}); ` +
`replaced — the original file is in the .bak backup.`,
);
}
hooks = {};
}
const events = opts.events ?? [...CLAUDE_HOOK_EVENTS];
let removedPrior = 0;
const installed: Array<{ event: ClaudeHookEvent; command: string }> = [];
for (const event of events) {
let groups = hooks[event];
if (!Array.isArray(groups)) {
if (groups !== undefined) {
notes.push(`WARNING: existing hooks.${event} was not an array; replaced — original in the .bak backup.`);
}
groups = [];
}
const { kept, removed } = stripOurEntries(groups as unknown[]);
removedPrior += removed;
const command = buildPortableClaudeHookCommand(event, opts.env);
const timeout = opts.timeoutSecs?.[event] ?? CLAUDE_HOOK_DEFAULT_TIMEOUT_SECS[event];
const entry: HookCommandEntry = {
type: 'command',
command,
@@ -281,9 +573,7 @@ export function writeClaudeHooks(
hooks[event] = kept;
installed.push({ event, command });
}
settings.hooks = hooks;
let backupPath: string | null = null;
if (existed && brokenBackupPath === null) {
backupPath = `${settingsPath}.bak`;
@@ -291,17 +581,29 @@ export function writeClaudeHooks(
}
atomicWriteJson(settingsPath, settings);
// [D12] dedupe: the committed carrier now owns these events — remove any
// local copies so nothing double-fires on this machine.
const localCleanup = removeClaudeHooksAt(claudeSettingsPath(workspaceDir));
if (localCleanup.removed > 0) {
notes.push(
`removed ${localCleanup.removed} local settings.local.json entr${localCleanup.removed === 1 ? 'y' : 'ies'} — the committed carrier owns the events now [D12]`,
);
}
return { settingsPath, installed, removedPrior, backupPath, brokenBackupPath, notes };
}
/**
* Remove ONLY marker-carrying entries [G5]. A parse-broken file is left
* untouched (removal must never destroy what it cannot read) the note says
* so. Event arrays we emptied lose their key; an emptied hooks object loses
* its key; foreign structure survives.
* Remove ONLY entries carrying the given marker [G5]. A parse-broken file is
* left untouched (removal must never destroy what it cannot read) the note
* says so. Event arrays we emptied lose their key; an emptied hooks object
* loses its key; foreign structure (including other-marker gbrain entries)
* survives.
*/
export function removeClaudeHooks(workspaceDir: string): RemoveClaudeHooksResult {
const settingsPath = claudeSettingsPath(workspaceDir);
export function removeClaudeHooksAt(
settingsPath: string,
marker: string = GBRAIN_HOOK_MARKER_VALUE,
): RemoveClaudeHooksResult {
const notes: string[] = [];
if (!existsSync(settingsPath)) {
return { settingsPath, removed: 0, backupPath: null, notes: ['no settings file — nothing to remove'] };
@@ -338,7 +640,7 @@ export function removeClaudeHooks(workspaceDir: string): RemoveClaudeHooksResult
for (const event of Object.keys(hooks)) {
const groups = hooks[event];
if (!Array.isArray(groups)) continue; // structurally foreign — never touch
const { kept, removed: n } = stripOurEntries(groups);
const { kept, removed: n } = stripOurEntries(groups, marker);
removed += n;
if (n === 0) continue;
if (kept.length === 0) {
@@ -360,6 +662,156 @@ export function removeClaudeHooks(workspaceDir: string): RemoveClaudeHooksResult
return { settingsPath, removed, backupPath, notes };
}
/**
* Workspace-lane removal cleans BOTH carriers (local + committed [D12]);
* the returned settingsPath/backup describe the local one, with
* committed-file actions reported via notes.
*/
export function removeClaudeHooks(workspaceDir: string): RemoveClaudeHooksResult {
const local = removeClaudeHooksAt(claudeSettingsPath(workspaceDir));
const committed = removeClaudeHooksAt(claudeCommittedSettingsPath(workspaceDir));
const notes = [...local.notes];
if (committed.removed > 0) {
notes.push(`also removed ${committed.removed} entr${committed.removed === 1 ? 'y' : 'ies'} from the committed ${committed.settingsPath} [D12]`);
} else {
notes.push(...committed.notes.map((n) => `(committed carrier) ${n}`));
}
return {
settingsPath: local.settingsPath,
removed: local.removed + committed.removed,
backupPath: local.backupPath,
notes,
};
}
// ── permissions.allow writers (harness lane, #4043) ────────────────────────
export interface PermissionsAllowResult {
settingsPath: string;
/** add: entry appended this run. remove: number of occurrences removed. */
added?: boolean;
removed?: number;
backupPath: string | null;
notes: string[];
}
/**
* Append one entry to `permissions.allow` (set semantics present means
* no-op). Stamps NO marker: permissions.allow is an array of plain strings,
* so ownership is recorded on the harness receipt (exact-string removal),
* never in the file and a marker object here would false-positive
* status.ts's whole-file `hooksInstalled` substring probe. Foreign entries
* and every other settings key survive. Broken JSON aborts (user-scope
* discipline this writer only ever targets user-scope files).
*/
export function addPermissionsAllowEntry(
settingsPath: string,
entry: string,
): PermissionsAllowResult {
const { settings, existed, notes } = loadSettings(settingsPath);
let permissions = settings.permissions as Record<string, unknown> | undefined;
if (typeof permissions !== 'object' || permissions === null || Array.isArray(permissions)) {
if (permissions !== undefined) {
// Fail CLOSED (same stance as broken JSON): "permissions" is the host's
// security policy — replacing a shape we don't understand could erase
// deny/ask rules or a future settings schema (ship-review P2).
throw new Error(
`${settingsPath}: existing "permissions" key is not an object ` +
`(${JSON.stringify(permissions).slice(0, 80)}) — refusing to rewrite security policy this writer ` +
`does not understand. Fix the file by hand, then re-run.`,
);
}
permissions = {};
}
let allow = permissions.allow as unknown[] | undefined;
if (!Array.isArray(allow)) {
if (allow !== undefined) {
throw new Error(
`${settingsPath}: existing permissions.allow is not an array — refusing to rewrite security policy ` +
`this writer does not understand. Fix the file by hand, then re-run.`,
);
}
allow = [];
}
if (allow.some((e) => e === entry)) {
return { settingsPath, added: false, backupPath: null, notes: [...notes, `${entry} already allowed — no change`] };
}
allow.push(entry);
permissions.allow = allow;
settings.permissions = permissions;
let backupPath: string | null = null;
if (existed) {
backupPath = backupPathFor(settingsPath, 'timestamped');
copyFileSync(settingsPath, backupPath);
}
atomicWriteJson(settingsPath, settings, 0o600); // user-scope-only writer [X11]
return { settingsPath, added: true, backupPath, notes };
}
/**
* Remove EXACTLY the given string from `permissions.allow`. Parse-broken file
* is left untouched (the removeClaudeHooks precedent removal never destroys
* what it cannot read). Keys are dropped only when OUR removal emptied them.
* Honest edge (stated in consent copy): if the user had independently allowed
* the same string, this removes it too set semantics carry no provenance.
*/
export function removePermissionsAllowEntry(
settingsPath: string,
entry: string,
): PermissionsAllowResult {
if (!existsSync(settingsPath)) {
return { settingsPath, removed: 0, backupPath: null, notes: ['no settings file — nothing to remove'] };
}
let loaded: LoadedSettings;
try {
loaded = loadSettings(settingsPath);
} catch (e) {
return {
settingsPath,
removed: 0,
backupPath: null,
notes: [
`WARNING: ${settingsPath} is not valid JSON (${(e as Error).message}); ` +
`left untouched — remove the "${entry}" permissions.allow entry by hand or fix the JSON and re-run.`,
],
};
}
const { settings, notes } = loaded;
const permissions = settings.permissions as Record<string, unknown> | undefined;
if (typeof permissions !== 'object' || permissions === null || Array.isArray(permissions)) {
return { settingsPath, removed: 0, backupPath: null, notes: ['no permissions object — nothing to remove'] };
}
const allow = permissions.allow as unknown[] | undefined;
if (!Array.isArray(allow)) {
return { settingsPath, removed: 0, backupPath: null, notes: ['no permissions.allow array — nothing to remove'] };
}
const kept = allow.filter((e) => e !== entry);
const removed = allow.length - kept.length;
if (removed === 0) {
return { settingsPath, removed: 0, backupPath: null, notes: [...notes, `${entry} not present — nothing to remove`] };
}
if (kept.length > 0) {
permissions.allow = kept;
} else {
delete permissions.allow; // emptied by OUR removal — drop the key
}
if (Object.keys(permissions).length === 0) {
delete settings.permissions;
}
const backupPath = backupPathFor(settingsPath, 'timestamped');
copyFileSync(settingsPath, backupPath);
atomicWriteJson(settingsPath, settings, 0o600); // user-scope-only writer [X11]
return { settingsPath, removed, backupPath, notes };
}
// ── MCP registration argv builders [G1, CX-P1.4, ENG-7] ────────────────────
export interface ClaudeMcpRegistration {

Some files were not shown because too many files have changed in this diff Show More