mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-16 01:42:23 +00:00
Compare commits
83
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e678f467d | ||
|
|
0c78213d92 | ||
|
|
e38e4a845b | ||
|
|
526595ce01 | ||
|
|
2d8c772171 | ||
|
|
bdef2dada5 | ||
|
|
cc902834d5 | ||
|
|
eaca6a94d8 | ||
|
|
7df02405bc | ||
|
|
bf0e8795f8 | ||
|
|
a534240d2c | ||
|
|
6db8dc8a34 | ||
|
|
750275533f | ||
|
|
17e36adb57 | ||
|
|
519c3748da | ||
|
|
fcbf547841 | ||
|
|
422438bfe7 | ||
|
|
ccc9f171c0 | ||
|
|
a90547e6fe | ||
|
|
56a272f18d | ||
|
|
40c5774fa8 | ||
|
|
71fb120514 | ||
|
|
3577432456 | ||
|
|
39687cb7fe | ||
|
|
6d25dd0711 | ||
|
|
4922905fb9 | ||
|
|
3ebda1fc87 | ||
|
|
1a06fc100a | ||
|
|
180c648ba0 | ||
|
|
181fc8c1a2 | ||
|
|
4deee227be | ||
|
|
52140808fd | ||
|
|
83a4a94c38 | ||
|
|
0f03a0f929 | ||
|
|
2b8c200b6e | ||
|
|
418dc15437 | ||
|
|
bf0a49bf97 | ||
|
|
f7b8890b8d | ||
|
|
9f598e4b20 | ||
|
|
296222eff0 | ||
|
|
c439fad23e | ||
|
|
dd99e40c2a | ||
|
|
6a905a1e5b | ||
|
|
d941e9f918 | ||
|
|
26578a2478 | ||
|
|
7fdcd8bd2e | ||
|
|
6411150071 | ||
|
|
ac402f55f8 | ||
|
|
cf3527a40f | ||
|
|
e2b2819e63 | ||
|
|
2ae5d60b98 | ||
|
|
bd4c976a85 | ||
|
|
9b9bd8b241 | ||
|
|
fd9bb12b42 | ||
|
|
5bd2c51053 | ||
|
|
189bf856ee | ||
|
|
735dec83b7 | ||
|
|
fd0e371d5b | ||
|
|
136fc109c1 | ||
|
|
1243a7d3bb | ||
|
|
44eea64084 | ||
|
|
45bd04ff9f | ||
|
|
8a626999f0 | ||
|
|
033029f25d | ||
|
|
a729ca8a8c | ||
|
|
5087507de0 | ||
|
|
ed6e4e3219 | ||
|
|
f8b0ececcb | ||
|
|
ca260baaaa | ||
|
|
0c485415a5 | ||
|
|
0cfedd026d | ||
|
|
b92cc967df | ||
|
|
3eccd4ccd6 | ||
|
|
154814b095 | ||
|
|
9b720b04af | ||
|
|
e8785c1ab1 | ||
|
|
638dd0d247 | ||
|
|
697016f69d | ||
|
|
758a2d4293 | ||
|
|
52389dbe5b | ||
|
|
fb141969f5 | ||
|
|
fa5ec8399f | ||
|
|
3ce296e315 |
@@ -6,6 +6,11 @@
|
||||
DATABASE_URL=postgresql://postgres:postgres@localhost:5433/gbrain_test
|
||||
# Option B: Real Supabase instance (tests the actual production path)
|
||||
# DATABASE_URL=postgresql://postgres.[project-ref]:[password]@aws-0-us-east-1.pooler.supabase.com:6543/postgres
|
||||
# NOTE (#3485): destructive tests enforce a database-name floor — the name must
|
||||
# carry "test" as a word segment (gbrain_test passes; Supabase's default
|
||||
# "postgres" does not). Use a dedicated test project/database, or opt the exact
|
||||
# name in one-shot: GBRAIN_E2E_ALLOW_DB=postgres bun run test:e2e
|
||||
# (never a shell-profile export — that would permanently disarm the floor).
|
||||
|
||||
# Tier 2 (required for skill tests, optional for mechanical tests)
|
||||
OPENAI_API_KEY=sk-...
|
||||
|
||||
@@ -61,10 +61,15 @@ jobs:
|
||||
- name: Run JSONB double-encode parity tests on real Postgres
|
||||
env:
|
||||
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/gbrain_test
|
||||
# #3485 preload guard: this job intentionally tests against a DB.
|
||||
GBRAIN_TEST_ALLOW_DATABASE_URL: '1'
|
||||
# --timeout also raises bun's 5s default hook budget (beforeAll/afterAll
|
||||
# do NOT inherit a test's third-arg timeout; verified on bun 1.3.x).
|
||||
# Every runner script in scripts/ passes it; bare invocations must too.
|
||||
run: bun test --timeout=60000 test/e2e/op-checkpoint-jsonb-parity.test.ts test/e2e/jsonb-roundtrip.test.ts
|
||||
# phantom-redirect rides this job: its Postgres arm is the other
|
||||
# engine-parity backstop and no other CI lane carries DATABASE_URL to it
|
||||
# (the unit wrappers strip the URL per #3485).
|
||||
run: bun test --timeout=60000 test/e2e/op-checkpoint-jsonb-parity.test.ts test/e2e/jsonb-roundtrip.test.ts test/phantom-redirect-engine-parity.test.ts
|
||||
|
||||
tier1:
|
||||
name: Tier 1 (Mechanical)
|
||||
@@ -91,9 +96,13 @@ jobs:
|
||||
bun-version: 1.3.13
|
||||
- run: bun install
|
||||
- name: Run Tier 1 E2E tests
|
||||
run: bun test --timeout=60000 test/e2e/mechanical.test.ts test/e2e/mcp.test.ts
|
||||
# job-isolation rides tier1 deliberately: e2e.yml runs only explicitly
|
||||
# NAMED files (no glob) — an unwired e2e file is silent coverage loss.
|
||||
run: bun test --timeout=60000 test/e2e/mechanical.test.ts test/e2e/mcp.test.ts test/e2e/job-isolation.test.ts
|
||||
env:
|
||||
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/gbrain_test
|
||||
# #3485 preload guard: this job intentionally tests against a DB.
|
||||
GBRAIN_TEST_ALLOW_DATABASE_URL: '1'
|
||||
|
||||
tier2:
|
||||
name: Tier 2 (LLM Skills)
|
||||
@@ -161,6 +170,8 @@ jobs:
|
||||
run: bun test --timeout=60000 test/e2e/skills.test.ts test/e2e/zeroentropy-live.test.ts
|
||||
env:
|
||||
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/gbrain_test
|
||||
# #3485 preload guard: this job intentionally tests against a DB.
|
||||
GBRAIN_TEST_ALLOW_DATABASE_URL: '1'
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
# v0.33.3.0: ZE live API tests skip gracefully when this is unset,
|
||||
|
||||
@@ -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.
|
||||
@@ -18,6 +18,11 @@ on:
|
||||
# label so we don't fan out on unrelated label changes.
|
||||
types: [labeled, synchronize, reopened]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
run_grok_door:
|
||||
description: 'Run the grok-door job (pre-secret posture: label or this input only)'
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -64,6 +69,8 @@ jobs:
|
||||
- name: Run heavy tests
|
||||
env:
|
||||
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/gbrain_test
|
||||
# #3485 preload guard: this job intentionally tests against a DB.
|
||||
GBRAIN_TEST_ALLOW_DATABASE_URL: '1'
|
||||
run: bun run test:heavy
|
||||
|
||||
# Agent-bootstrap offline Docker e2e [A7] — placeholder registration.
|
||||
@@ -102,14 +109,16 @@ 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` +
|
||||
# `grok` + `opencode` 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,22 +127,41 @@ jobs:
|
||||
contains(github.event.pull_request.labels.*.name, 'heavy-tests')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
env:
|
||||
# Open the hermes/grok opt-in doors here so binary/auth absence — not
|
||||
# the opt-in var — is what skips (same posture as the claude/codex
|
||||
# doors). The grok door's keyless tier additionally self-skips without
|
||||
# a grok binary, which a stock runner does not have.
|
||||
GBRAIN_REAL_HERMES_E2E: '1'
|
||||
GBRAIN_REAL_GROK_E2E: '1'
|
||||
GBRAIN_REAL_OPENCODE_E2E: '1'
|
||||
# Pin so a provisioned runner's grok version-shape test asserts against
|
||||
# the supported version (and a colliding community `grok` binary fails
|
||||
# loud instead of running the keyless tier confusingly).
|
||||
GROK_VERSION: "1.0.4"
|
||||
# Same posture for opencode: a provisioned runner's version pin.
|
||||
OPENCODE_VERSION: "1.18.18"
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
||||
with:
|
||||
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 \
|
||||
test/e2e/install-real-grok.serial.test.ts \
|
||||
test/e2e/install-real-opencode.serial.test.ts; do
|
||||
[ -f "$f" ] && files+=("$f")
|
||||
done
|
||||
if [ "${#files[@]}" -eq 0 ]; then
|
||||
@@ -144,3 +172,696 @@ 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: "868ed3a91e0fabbff6d7418b3ede82bf4833652ec4e77196a42852fb35a9e5b9"
|
||||
GBRAIN_REAL_HERMES_E2E: '1'
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- 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
|
||||
# Preserve the FULL bun output UNCONDITIONALLY (upload stays
|
||||
# failure-gated): the zero-pass failure class below exits with the
|
||||
# summary as its only trace, and bun prints failure details before
|
||||
# the summary, so the 40-line tail can drop exactly the lines a
|
||||
# paid-CI triage needs.
|
||||
cp door.txt "$GBRAIN_E2E_EVIDENCE_DIR/" 2>/dev/null || true
|
||||
if [ "$EXIT" -ne 0 ]; then
|
||||
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
|
||||
|
||||
# Grok door e2e (xAI Grok Build): PROVISIONS the real grok binary via the
|
||||
# pinned npm package (registry integrity verified — stronger than the
|
||||
# curl-installer path; both pins live in docs/mcp/GROK-CLI-PIN.md, enforced
|
||||
# against this file by scripts/check-grok-pin.sh in `bun run verify`).
|
||||
# KEYLESS-FIRST ordering (deliberate divergence from hermes-door): grok's
|
||||
# mcp add/list/doctor run keyless, so the compat tier runs and banks its
|
||||
# coverage BEFORE the secret precondition — a missing XAI_API_KEY still
|
||||
# fails this job loudly, but only after the free tier proved the install
|
||||
# surface, so pre-secret runs are diagnostic instead of pure red.
|
||||
#
|
||||
# PRE-SECRET GATING POSTURE: `real-agent-e2e` label or the run_grok_door
|
||||
# dispatch input ONLY — deliberately NOT `schedule` and NOT the generic
|
||||
# `heavy-tests` label, so an absent XAI_API_KEY secret cannot paint nightly
|
||||
# heavy runs (or unrelated heavy-labeled PRs) red. The commit that lands
|
||||
# AFTER an admin creates the XAI_API_KEY secret (an external prerequisite,
|
||||
# not a code change) re-adds: the schedule leg, the heavy-tests label leg,
|
||||
# a default-on dispatch, and a latest-version canary matrix leg
|
||||
# (continue-on-error, schedule-scoped, own timeout) so the pinned lane
|
||||
# stays deterministic while the canary tracks what users actually run.
|
||||
grok-door:
|
||||
name: Grok door e2e (real binary, keyless-first)
|
||||
if: |
|
||||
(github.event_name == 'pull_request' &&
|
||||
contains(github.event.pull_request.labels.*.name, 'real-agent-e2e')) ||
|
||||
(github.event_name == 'workflow_dispatch' &&
|
||||
inputs.run_grok_door == true)
|
||||
runs-on: ubuntu-latest
|
||||
# Measured local door wall-time: keyless tier ~29s + one-time compiled
|
||||
# gbrain build (~2-4 min) + npm install (~10s); paid SMOKE turn budget
|
||||
# 2 x 240s. 20 min = measured + >50% headroom (GROK-CLI-PIN.md).
|
||||
timeout-minutes: 20
|
||||
env:
|
||||
# Pin values documented in docs/mcp/GROK-CLI-PIN.md — update them
|
||||
# together, deliberately, after reviewing upstream changes
|
||||
# (scripts/check-grok-pin.sh fails `bun run verify` on drift).
|
||||
GROK_VERSION: "1.0.4"
|
||||
GROK_NPM_PACKAGE: "@xai-official/grok"
|
||||
GROK_NPM_INTEGRITY: "sha512-Nu3SFXTqwvCQr/LQFwrQYgngJhUQwX2h9ZSgzW4HowidjbPBWtMVO0xI88d2z6/zlDSNaT5YP/uk+2DthKQMsg=="
|
||||
# Per-platform payload pins: the wrapper's integrity covers only the
|
||||
# wrapper tarball; the binary that EXECUTES is the platform sub-package.
|
||||
GROK_NPM_LINUX_X64_INTEGRITY: "sha512-Dan2LfKcFBiabuDGHaGgMT8Ndzibo2ljvSjh4MlpV5117JL+S/0KMbdyYpk+13d7t+4znniW1cm+rRwUGSAvtw=="
|
||||
GROK_NPM_LINUX_ARM64_INTEGRITY: "sha512-zGK42Eq3ZmIa7cSVnl6CiJ4cxTCMsNLQCmCoLJhy5eZXfAvZ1DA3K3HXmKCj4OScX8SalYlp7mx8HWl9Y6gytw=="
|
||||
GBRAIN_REAL_GROK_E2E: '1'
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
||||
with:
|
||||
bun-version: 1.3.13
|
||||
- run: bun install
|
||||
|
||||
- name: Prepare evidence dir
|
||||
run: |
|
||||
echo "GBRAIN_E2E_EVIDENCE_DIR=$RUNNER_TEMP/grok-door-evidence" >> "$GITHUB_ENV"
|
||||
mkdir -p "$RUNNER_TEMP/grok-door-evidence"
|
||||
|
||||
# Compile gbrain ONCE for both bun test invocations below —
|
||||
# ensureCompiledGbrain's cache is per-process, so without this the
|
||||
# keyless and paid runs each pay the 2-4 min compile.
|
||||
- name: Build gbrain (compile once for both door runs)
|
||||
run: |
|
||||
bun build --compile --outfile "$RUNNER_TEMP/gbrain-door-bin" src/cli.ts
|
||||
echo "GBRAIN_COMPILED_BIN=$RUNNER_TEMP/gbrain-door-bin" >> "$GITHUB_ENV"
|
||||
|
||||
# SECRETLESS provisioning: the npm registry verifies the per-platform
|
||||
# payload against its integrity metadata; the pre-check pins that the
|
||||
# registry still serves the SAME integrity we observed (a re-published
|
||||
# 1.0.4 becomes a loud re-pin decision, not silently different code
|
||||
# running next to secrets in later steps). Version assert lives here
|
||||
# too — before any secret-bearing step.
|
||||
- name: Install grok (pinned npm package)
|
||||
timeout-minutes: 10
|
||||
run: |
|
||||
served=$(npm view "$GROK_NPM_PACKAGE@$GROK_VERSION" dist.integrity 2>/dev/null || true)
|
||||
if [ "$served" != "$GROK_NPM_INTEGRITY" ]; then
|
||||
echo "::error::grok npm integrity drift for $GROK_NPM_PACKAGE@$GROK_VERSION — registry serves '$served', pinned '$GROK_NPM_INTEGRITY'. Re-pin deliberately: update the stamps in docs/mcp/GROK-CLI-PIN.md + this workflow after reviewing upstream (see the pin doc's re-observation checklist)." >&2
|
||||
exit 1
|
||||
fi
|
||||
# The platform sub-package is the binary that actually runs — pin it
|
||||
# too (per-arch; ubuntu-latest is x64 today, arm64 pinned for a
|
||||
# future runner switch).
|
||||
arch=$(uname -m)
|
||||
case "$arch" in
|
||||
x86_64) plat_pkg="$GROK_NPM_PACKAGE-linux-x64"; plat_pin="$GROK_NPM_LINUX_X64_INTEGRITY" ;;
|
||||
aarch64|arm64) plat_pkg="$GROK_NPM_PACKAGE-linux-arm64"; plat_pin="$GROK_NPM_LINUX_ARM64_INTEGRITY" ;;
|
||||
*) echo "::error::unsupported runner arch for the grok payload pin: $arch" >&2; exit 1 ;;
|
||||
esac
|
||||
plat_served=$(npm view "$plat_pkg@$GROK_VERSION" dist.integrity 2>/dev/null || true)
|
||||
if [ "$plat_served" != "$plat_pin" ]; then
|
||||
echo "::error::grok platform payload integrity drift for $plat_pkg@$GROK_VERSION — registry serves '$plat_served', pinned '$plat_pin'. Re-pin deliberately (GROK-CLI-PIN.md stamps + this workflow)." >&2
|
||||
exit 1
|
||||
fi
|
||||
npm install -g "$GROK_NPM_PACKAGE@$GROK_VERSION"
|
||||
if ! command -v grok >/dev/null 2>&1; then
|
||||
echo "::error::grok did not resolve on PATH after npm install" >&2
|
||||
exit 1
|
||||
fi
|
||||
version_output=$(grok --version)
|
||||
echo "$version_output"
|
||||
# Observed shape: `grok 1.0.4 (buildhash)` (GROK-CLI-PIN.md).
|
||||
if ! printf '%s' "$version_output" | grep -qF "grok $GROK_VERSION"; then
|
||||
echo "::error::grok version drift — expected 'grok $GROK_VERSION' in: $version_output (see docs/mcp/GROK-CLI-PIN.md triage table)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# KEYLESS TIER FIRST — banks the free compat coverage (documented-shape
|
||||
# registration, TOML schema, doctor handshake proving the seven-verb
|
||||
# surface) before anything needs the secret. XAI_API_KEY is absent from
|
||||
# this step by construction, so the paid describe self-skips.
|
||||
- name: Run grok door tests (keyless tier)
|
||||
run: |
|
||||
EXIT=0
|
||||
bun test --timeout=600000 test/e2e/install-real-grok.serial.test.ts > door-keyless.txt 2>&1 || EXIT=$?
|
||||
tail -40 door-keyless.txt
|
||||
cp door-keyless.txt "$GBRAIN_E2E_EVIDENCE_DIR/" 2>/dev/null || true
|
||||
if [ "$EXIT" -ne 0 ]; then
|
||||
exit "$EXIT"
|
||||
fi
|
||||
# Exact expected shape for this tier: 4 keyless tests pass, the
|
||||
# 1 paid test skips. Zero-pass or partial-pass refuses green.
|
||||
pass_count=$(grep -Eo '[0-9]+ pass' door-keyless.txt | tail -1 | grep -Eo '^[0-9]+' || true)
|
||||
if [ -z "$pass_count" ] || [ "$pass_count" -lt 4 ]; then
|
||||
echo "::error::grok door keyless tier expected 4 passing tests, summary shows '${pass_count:-none}' — refusing to go green (see docs/mcp/GROK-CLI-PIN.md triage table)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Preconditions (secret present)
|
||||
env:
|
||||
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
|
||||
run: |
|
||||
if [ -z "$XAI_API_KEY" ]; then
|
||||
echo "::error::XAI_API_KEY secret is empty — the keyless tier above already ran (its coverage is banked); the paid SMOKE needs the secret. Admin: create the XAI_API_KEY repo/environment secret (console.x.ai), then re-run. Fork PRs get no secrets from GitHub." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Named bad-key preflight: key-rot fails HERE, at a step named for it,
|
||||
# instead of surfacing as a confusing SMOKE failure (GROK-CLI-PIN.md
|
||||
# triage table). One minimal paid probe.
|
||||
- name: Auth preflight (bad-key tripwire)
|
||||
env:
|
||||
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
|
||||
run: |
|
||||
export GROK_HOME="$RUNNER_TEMP/grok-preflight-home"
|
||||
mkdir -p "$GROK_HOME"
|
||||
# Verbatim kill-switch homes (update together): seedGrokConfig in
|
||||
# test/helpers/agent-harness.ts and scenarioGrokInstall in
|
||||
# scripts/dx-explore.ts.
|
||||
printf '[cli]\nauto_update = false\n' > "$GROK_HOME/config.toml"
|
||||
# This step runs the third-party agent binary directly: never hand
|
||||
# it the WRITABLE step-metadata files (appending to GITHUB_ENV/PATH
|
||||
# poisons the later secret-bearing steps — the same channel
|
||||
# grokChildEnv scrubs for test-spawned children), and kill its web
|
||||
# tools like the door SMOKE does.
|
||||
out=$(env -u GITHUB_ENV -u GITHUB_PATH -u GITHUB_OUTPUT -u GITHUB_STATE -u GITHUB_STEP_SUMMARY \
|
||||
grok -p "reply with exactly: PREFLIGHT-OK" --output-format plain --disable-web-search 2>&1) || {
|
||||
echo "::error::grok auth preflight failed — the XAI_API_KEY secret is present but rejected (rotate it at console.x.ai; see GROK-CLI-PIN.md triage table). Output: ${out:0:300}" >&2
|
||||
exit 1
|
||||
}
|
||||
echo "auth preflight ok"
|
||||
|
||||
- name: Run grok door tests (full — paid SMOKE included)
|
||||
env:
|
||||
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
|
||||
run: |
|
||||
EXIT=0
|
||||
bun test --timeout=600000 test/e2e/install-real-grok.serial.test.ts > door.txt 2>&1 || EXIT=$?
|
||||
tail -40 door.txt
|
||||
cp door.txt "$GBRAIN_E2E_EVIDENCE_DIR/" 2>/dev/null || true
|
||||
if [ "$EXIT" -ne 0 ]; then
|
||||
exit "$EXIT"
|
||||
fi
|
||||
# PAID-SENTINEL: with the key present, a skipping paid tier must
|
||||
# never read as green (the split-gating false-green class). The
|
||||
# grep target is the suite's literal skip log — mirrored in
|
||||
# test/e2e/install-real-grok.serial.test.ts (change together).
|
||||
if grep -q 'SKIP paid tier' door.txt; then
|
||||
echo "::error::grok door paid tier skipped despite a present XAI_API_KEY — hasGrokAuth() gate drift; refusing to go green" >&2
|
||||
exit 1
|
||||
fi
|
||||
pass_count=$(grep -Eo '[0-9]+ pass' door.txt | tail -1 | grep -Eo '^[0-9]+' || true)
|
||||
if [ -z "$pass_count" ] || [ "$pass_count" -lt 5 ]; then
|
||||
echo "::error::grok door full run expected 5 passing tests (6 once the JSON tool-call test lands), summary shows '${pass_count:-none}'" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Auto-update tripwire: the seeded [cli] auto_update=false is the whole
|
||||
# kill-switch (no env form observed) — a version that MOVED mid-job
|
||||
# means it failed and the pins above are no longer what just ran.
|
||||
- name: Version re-check (mid-job drift tripwire)
|
||||
if: always()
|
||||
run: |
|
||||
if command -v grok >/dev/null 2>&1; then
|
||||
version_output=$(grok --version || true)
|
||||
if ! printf '%s' "$version_output" | grep -qF "grok $GROK_VERSION"; then
|
||||
echo "::error::grok version moved mid-job — auto-update kill-switch failed (expected 'grok $GROK_VERSION', got: $version_output)" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Scrub credentials from evidence (defensive)
|
||||
if: failure() && env.GBRAIN_E2E_EVIDENCE_DIR != ''
|
||||
env:
|
||||
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
|
||||
run: |
|
||||
# Same triple as hermes-door: filenames, symlinks, content. Auth is
|
||||
# env-only here (the job never writes the key to disk — stronger
|
||||
# than the hermes .env posture), so the content grep is the layer
|
||||
# that matters for grok-written logs on the failure path.
|
||||
find "$GBRAIN_E2E_EVIDENCE_DIR" -type f \( -name '.env' -o -name '*.env' -o -name 'mcp_credentials.json' \) -exec rm -f {} + 2>/dev/null || true
|
||||
find "$GBRAIN_E2E_EVIDENCE_DIR" -type l -delete 2>/dev/null || true
|
||||
if [ -n "$XAI_API_KEY" ]; then
|
||||
grep -rlF "$XAI_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 grok door evidence
|
||||
if: failure() && env.GBRAIN_E2E_EVIDENCE_DIR != ''
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: grok-door-evidence
|
||||
path: ${{ env.GBRAIN_E2E_EVIDENCE_DIR }}
|
||||
retention-days: 14
|
||||
if-no-files-found: ignore
|
||||
|
||||
# Auth travels env-only, but grok MAY persist derived credentials after
|
||||
# an authed turn (the inventory is pending — GROK-CLI-PIN.md); remove
|
||||
# the known candidate unconditionally so nothing outlives the job even
|
||||
# on a future self-hosted runner.
|
||||
- name: Remove grok credentials (unconditional)
|
||||
if: always()
|
||||
run: |
|
||||
rm -f ~/.grok/mcp_credentials.json
|
||||
rm -rf "$RUNNER_TEMP/grok-preflight-home"
|
||||
# Cancellation/timeout bypasses the suite's afterAll cleanup — the
|
||||
# hermetic homes carry no key file (env-only auth) but may hold
|
||||
# grok-derived credentials once the authed inventory lands.
|
||||
rm -rf /tmp/gb-grok-* 2>/dev/null || true
|
||||
|
||||
# opencode door e2e (SST opencode): PROVISIONS the real opencode binary via
|
||||
# the pinned npm package (wrapper + per-platform payload integrities
|
||||
# verified — both pins live in docs/mcp/OPENCODE-CLI-PIN.md, enforced
|
||||
# against this file by scripts/check-opencode-pin.sh in `bun run verify`).
|
||||
#
|
||||
# DAY-ONE FULL POSTURE (a step past grok's pre-secret gating, deliberate):
|
||||
# opencode's anonymous free tier drives MCP tool calls keyless (observed,
|
||||
# load-bearing — OPENCODE-CLI-PIN.md §One-shot), so the ENTIRE core door —
|
||||
# including the nonce SMOKE — runs with no secret; and the paid anthropic
|
||||
# leg rides the ANTHROPIC_API_KEY secret that already exists (hermes-door
|
||||
# consumes it). So this job takes the hermes-door triggers (nightly +
|
||||
# labels + dispatch, cadence policy: nightly for the NEWEST door agent)
|
||||
# with grok-door's internals (keyless-first ordering, secretless pinned
|
||||
# provisioning, sentinels, scrub triple, unconditional credential removal).
|
||||
# No dedicated dispatch input: any workflow_dispatch already passes the
|
||||
# non-PR arm, so an input would be dead yaml.
|
||||
opencode-door:
|
||||
name: opencode door e2e (real binary, keyless SMOKE)
|
||||
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
|
||||
# Measured local door wall-time: full 6-test run 35.8s + one-time
|
||||
# compiled gbrain build (~2-4 min) + npm install (~15s); free-tier +
|
||||
# paid turn budgets 2 x 240s each. 20 min = measured + >50% headroom.
|
||||
timeout-minutes: 20
|
||||
env:
|
||||
# Pin values documented in docs/mcp/OPENCODE-CLI-PIN.md — update them
|
||||
# together, deliberately, after reviewing upstream changes
|
||||
# (scripts/check-opencode-pin.sh fails `bun run verify` on drift).
|
||||
OPENCODE_VERSION: "1.18.18"
|
||||
OPENCODE_NPM_PACKAGE: "opencode-ai"
|
||||
OPENCODE_NPM_INTEGRITY: "sha512-J+5HFq8tf+wPBBpBpMPSNjSytF2/EkNWYfFZh4si1d9auFbQriqDyqZv+vFUsLWERfdMU32Eajwuiq3rKBvZLQ=="
|
||||
# Per-platform payload pins: the wrapper's integrity covers only the
|
||||
# wrapper tarball; the binary that EXECUTES is the platform sub-package.
|
||||
OPENCODE_NPM_LINUX_X64_INTEGRITY: "sha512-WmeUnhljYJ252wywKTiW4bNDzsas2njpjPUEh0jM6HKNI4vFxJtREtzaWViY4AKEAcOkLWT8Ll17ixvcHz3AnA=="
|
||||
OPENCODE_NPM_LINUX_ARM64_INTEGRITY: "sha512-e8D3g0qJEIzawEg2+ygW3vkZjAYL2ssyAx4GbihjwXwZFvlZZy5zRWWzdz5KLBoHSTl0FB73vNtnNeXONyHpVQ=="
|
||||
GBRAIN_REAL_OPENCODE_E2E: '1'
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
||||
with:
|
||||
bun-version: 1.3.13
|
||||
- run: bun install
|
||||
|
||||
- name: Prepare evidence dir
|
||||
run: |
|
||||
echo "GBRAIN_E2E_EVIDENCE_DIR=$RUNNER_TEMP/opencode-door-evidence" >> "$GITHUB_ENV"
|
||||
mkdir -p "$RUNNER_TEMP/opencode-door-evidence"
|
||||
|
||||
# Compile gbrain ONCE for both bun test invocations below.
|
||||
- name: Build gbrain (compile once for both door runs)
|
||||
run: |
|
||||
bun build --compile --outfile "$RUNNER_TEMP/gbrain-door-bin" src/cli.ts
|
||||
echo "GBRAIN_COMPILED_BIN=$RUNNER_TEMP/gbrain-door-bin" >> "$GITHUB_ENV"
|
||||
|
||||
# SECRETLESS provisioning, pack-verify-install: `npm pack` DOWNLOADS
|
||||
# each artifact and reports the integrity of the BYTES it wrote, so the
|
||||
# asserts below cover the tarballs actually held — closing the
|
||||
# view-then-install TOCTOU (two registry round-trips a payload-swapping
|
||||
# registry could split). The wrapper then installs FROM the verified
|
||||
# local tarball, not a fresh registry resolve of the name. Payload
|
||||
# resolution, honestly: that install still fetches the platform
|
||||
# sub-package (opencode-linux-*) over the network; after the pack step
|
||||
# byte-confirms the registry's payload artifact matches its pin, npm
|
||||
# validates the install-time fetch against the same packument
|
||||
# integrity. No --ignore-scripts: opencode-ai's postinstall places the
|
||||
# platform binary (verified locally — with the flag the CLI refuses to
|
||||
# run). Version assert lives here too — before any secret-bearing step.
|
||||
- name: Install opencode (pinned npm package, pack-verify-install)
|
||||
timeout-minutes: 10
|
||||
run: |
|
||||
packdir=$(mktemp -d)
|
||||
read_integrity() {
|
||||
node -e 'let d;try{d=JSON.parse(require("fs").readFileSync(0,"utf8"))}catch{d=[]}process.stdout.write((Array.isArray(d)&&d[0]&&d[0].integrity)||"")'
|
||||
}
|
||||
pushd "$packdir" >/dev/null
|
||||
served=$(npm pack "$OPENCODE_NPM_PACKAGE@$OPENCODE_VERSION" --json 2>/dev/null | read_integrity || true)
|
||||
if [ "$served" != "$OPENCODE_NPM_INTEGRITY" ]; then
|
||||
echo "::error::opencode npm integrity drift for $OPENCODE_NPM_PACKAGE@$OPENCODE_VERSION — packed tarball integrity '$served', pinned '$OPENCODE_NPM_INTEGRITY'. Re-pin deliberately: update the stamps in docs/mcp/OPENCODE-CLI-PIN.md + this workflow after reviewing upstream (see the pin doc's re-observation checklist)." >&2
|
||||
exit 1
|
||||
fi
|
||||
arch=$(uname -m)
|
||||
case "$arch" in
|
||||
x86_64) plat_pkg="opencode-linux-x64"; plat_pin="$OPENCODE_NPM_LINUX_X64_INTEGRITY" ;;
|
||||
aarch64|arm64) plat_pkg="opencode-linux-arm64"; plat_pin="$OPENCODE_NPM_LINUX_ARM64_INTEGRITY" ;;
|
||||
*) echo "::error::unsupported runner arch for the opencode payload pin: $arch" >&2; exit 1 ;;
|
||||
esac
|
||||
plat_served=$(npm pack "$plat_pkg@$OPENCODE_VERSION" --json 2>/dev/null | read_integrity || true)
|
||||
if [ "$plat_served" != "$plat_pin" ]; then
|
||||
echo "::error::opencode platform payload integrity drift for $plat_pkg@$OPENCODE_VERSION — packed tarball integrity '$plat_served', pinned '$plat_pin'. Re-pin deliberately (OPENCODE-CLI-PIN.md stamps + this workflow)." >&2
|
||||
exit 1
|
||||
fi
|
||||
npm install -g ./opencode-ai-*.tgz
|
||||
popd >/dev/null
|
||||
rm -rf "$packdir"
|
||||
if ! command -v opencode >/dev/null 2>&1; then
|
||||
echo "::error::opencode did not resolve on PATH after npm install" >&2
|
||||
exit 1
|
||||
fi
|
||||
version_output=$(opencode --version)
|
||||
echo "$version_output"
|
||||
# Observed shape: BARE semver (`1.18.18` — no name, no hash); the
|
||||
# SST-vs-claimant discriminator (OPENCODE-CLI-PIN.md §Pin).
|
||||
if [ "$(printf '%s' "$version_output" | tr -d '[:space:]')" != "$OPENCODE_VERSION" ]; then
|
||||
echo "::error::opencode version drift — expected bare '$OPENCODE_VERSION', got: $version_output (see docs/mcp/OPENCODE-CLI-PIN.md triage table)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# KEYLESS TIER FIRST — and on opencode that includes the nonce SMOKE
|
||||
# (free tier). ANTHROPIC_API_KEY is absent from this step by
|
||||
# construction, so the paid describe self-skips.
|
||||
- name: Run opencode door tests (keyless tier — SMOKE included)
|
||||
run: |
|
||||
EXIT=0
|
||||
bun test --timeout=600000 test/e2e/install-real-opencode.serial.test.ts > door-keyless.txt 2>&1 || EXIT=$?
|
||||
tail -40 door-keyless.txt
|
||||
cp door-keyless.txt "$GBRAIN_E2E_EVIDENCE_DIR/" 2>/dev/null || true
|
||||
if [ "$EXIT" -ne 0 ]; then
|
||||
exit "$EXIT"
|
||||
fi
|
||||
# Exact expected shape for this tier: 5 keyless tests pass (T1, T2,
|
||||
# T2b, T3, T4-SMOKE), the 1 paid test skips. Zero/partial-pass
|
||||
# refuses green.
|
||||
pass_count=$(grep -Eo '[0-9]+ pass' door-keyless.txt | tail -1 | grep -Eo '^[0-9]+' || true)
|
||||
if [ -z "$pass_count" ] || [ "$pass_count" -lt 5 ]; then
|
||||
echo "::error::opencode door keyless tier expected 5 passing tests, summary shows '${pass_count:-none}' — refusing to go green (see docs/mcp/OPENCODE-CLI-PIN.md triage table)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Preconditions (secret present)
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
run: |
|
||||
if [ -z "$ANTHROPIC_API_KEY" ]; then
|
||||
echo "::error::ANTHROPIC_API_KEY secret is empty — the keyless tier above already ran (its coverage, including the SMOKE, is banked); the paid anthropic leg needs the secret hermes-door already consumes. Fork PRs get no secrets from GitHub." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Full run (paid anthropic leg included). The T5 models-gate inside the
|
||||
# suite is the named bad-pin tripwire: it validates the pinned model id
|
||||
# against the AUTHED `opencode models` list BEFORE any spend.
|
||||
- name: Run opencode door tests (full — paid anthropic leg included)
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
run: |
|
||||
EXIT=0
|
||||
bun test --timeout=600000 test/e2e/install-real-opencode.serial.test.ts > door.txt 2>&1 || EXIT=$?
|
||||
tail -40 door.txt
|
||||
cp door.txt "$GBRAIN_E2E_EVIDENCE_DIR/" 2>/dev/null || true
|
||||
if [ "$EXIT" -ne 0 ]; then
|
||||
exit "$EXIT"
|
||||
fi
|
||||
# PAID-SENTINEL: with the key present, a skipping paid tier must
|
||||
# never read as green (the split-gating false-green class). The
|
||||
# grep target is the suite's literal skip log — mirrored in
|
||||
# test/e2e/install-real-opencode.serial.test.ts (change together).
|
||||
if grep -q 'SKIP paid tier' door.txt; then
|
||||
echo "::error::opencode door paid tier skipped despite a present ANTHROPIC_API_KEY — hasOpencodeAuth() gate drift; refusing to go green" >&2
|
||||
exit 1
|
||||
fi
|
||||
pass_count=$(grep -Eo '[0-9]+ pass' door.txt | tail -1 | grep -Eo '^[0-9]+' || true)
|
||||
if [ -z "$pass_count" ] || [ "$pass_count" -lt 6 ]; then
|
||||
echo "::error::opencode door full run expected 6 passing tests, summary shows '${pass_count:-none}'" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Auto-update tripwire: the DOUBLE kill (config seed + env var) is the
|
||||
# whole defense — a version that MOVED mid-job means it failed and the
|
||||
# pins above are no longer what just ran.
|
||||
- name: Version re-check (mid-job drift tripwire)
|
||||
if: always()
|
||||
run: |
|
||||
if command -v opencode >/dev/null 2>&1; then
|
||||
version_output=$(opencode --version || true)
|
||||
if [ "$(printf '%s' "$version_output" | tr -d '[:space:]')" != "$OPENCODE_VERSION" ]; then
|
||||
echo "::error::opencode version moved mid-job — auto-update kill failed (expected '$OPENCODE_VERSION', got: $version_output)" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Scrub credentials from evidence (defensive)
|
||||
if: failure() && env.GBRAIN_E2E_EVIDENCE_DIR != ''
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
run: |
|
||||
# Same triple as the sibling doors, RE-KEYED for this lane: the
|
||||
# credential file candidate is opencode's auth.json and the content
|
||||
# grep sweeps ANTHROPIC_API_KEY (not XAI). Auth is env-only here —
|
||||
# the content grep is the layer that matters for opencode-written
|
||||
# logs on the failure path.
|
||||
find "$GBRAIN_E2E_EVIDENCE_DIR" -type f \( -name '.env' -o -name '*.env' -o -name 'auth.json' \) -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 opencode door evidence
|
||||
if: failure() && env.GBRAIN_E2E_EVIDENCE_DIR != ''
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: opencode-door-evidence
|
||||
path: ${{ env.GBRAIN_E2E_EVIDENCE_DIR }}
|
||||
retention-days: 14
|
||||
if-no-files-found: ignore
|
||||
|
||||
# Auth travels env-only, but a future login flow would persist
|
||||
# auth.json — remove the known candidate unconditionally so nothing
|
||||
# outlives the job even on a future self-hosted runner.
|
||||
- name: Remove opencode credentials (unconditional)
|
||||
if: always()
|
||||
run: |
|
||||
rm -f ~/.local/share/opencode/auth.json
|
||||
rm -rf /tmp/gb-opencode-* 2>/dev/null || true
|
||||
|
||||
# opencode canary: latest-version leg (schedule-scoped, continue-on-error,
|
||||
# own timeout — landed IN-WAVE, reversing the grok-style deferral, because
|
||||
# opencode ships near-continuously and a frozen pin goes stale in weeks;
|
||||
# the pinned lane above stays the deterministic gate while this tracks
|
||||
# what users actually run). Keyless tier only (incl. the free-tier SMOKE);
|
||||
# no secret ever reaches this job. A red here is a PIN-REFRESH SIGNAL
|
||||
# (OPENCODE-CLI-PIN.md §Pin-refresh cadence), never a gate.
|
||||
opencode-door-canary:
|
||||
name: opencode door canary (latest, keyless, non-gating)
|
||||
if: github.event_name == 'schedule'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
continue-on-error: true
|
||||
env:
|
||||
GBRAIN_REAL_OPENCODE_E2E: '1'
|
||||
# Deliberately NO OPENCODE_VERSION pin: T1 asserts the bare-semver
|
||||
# SHAPE only, and the suite runs against whatever `latest` is today.
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
||||
with:
|
||||
bun-version: 1.3.13
|
||||
- run: bun install
|
||||
- name: Build gbrain
|
||||
run: |
|
||||
bun build --compile --outfile "$RUNNER_TEMP/gbrain-door-bin" src/cli.ts
|
||||
echo "GBRAIN_COMPILED_BIN=$RUNNER_TEMP/gbrain-door-bin" >> "$GITHUB_ENV"
|
||||
- name: Install opencode@latest (unpinned — the whole point)
|
||||
timeout-minutes: 10
|
||||
run: |
|
||||
npm install -g opencode-ai@latest
|
||||
command -v opencode >/dev/null 2>&1
|
||||
echo "canary version: $(opencode --version)"
|
||||
- name: Run opencode door tests (keyless tier against latest)
|
||||
run: |
|
||||
EXIT=0
|
||||
bun test --timeout=600000 test/e2e/install-real-opencode.serial.test.ts > door-canary.txt 2>&1 || EXIT=$?
|
||||
tail -40 door-canary.txt
|
||||
if [ "$EXIT" -ne 0 ]; then
|
||||
echo "::warning::opencode canary red against latest — pin-refresh signal (OPENCODE-CLI-PIN.md §Pin-refresh cadence); the pinned lane is the gate."
|
||||
exit "$EXIT"
|
||||
fi
|
||||
|
||||
+64
-22
@@ -1,16 +1,17 @@
|
||||
<!-- gbrain-runbook-stamp: 0.45.9.0 -->
|
||||
<!-- gbrain-runbook-stamp: 0.46.4.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. -->
|
||||
|
||||
# BOOTSTRAP_FOR_AGENTS.md — become your human's persistent agent
|
||||
|
||||
You are an AI agent (Claude Code or Codex) reading this because a human pasted a
|
||||
block asking you to set yourself up as their persistent personal agent, with gbrain
|
||||
as your memory. This runbook is the judgment layer; the deterministic work lives in
|
||||
`gbrain bootstrap` subcommands with exit codes. Follow it top to bottom.
|
||||
You are an AI agent (Claude Code, Codex, or opencode) reading this because a human
|
||||
pasted a block asking you to set yourself up as their persistent personal agent, with
|
||||
gbrain as your memory. This runbook is the judgment layer; the deterministic work lives
|
||||
in `gbrain bootstrap` subcommands with exit codes. Follow it top to bottom.
|
||||
|
||||
**Scope note:** this path is for Claude Code and Codex (desktop apps or CLIs).
|
||||
**Scope note:** this path is for Claude Code, Codex, and opencode (desktop apps or
|
||||
CLIs; opencode = the SST terminal agent, opencode.ai — not OpenClaw).
|
||||
Running OpenClaw or Hermes? Use `INSTALL_FOR_AGENTS.md` instead.
|
||||
|
||||
**End state:** this folder is your workspace — identity files rendered from your
|
||||
@@ -82,8 +83,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
|
||||
@@ -93,12 +97,16 @@ you needed; report the count at the end (it feeds the install-time measurement).
|
||||
3. **Interview.** `gbrain bootstrap interview --init`, then ask the questions from
|
||||
the bank (the CLI prints them) in three batches, recording each answer verbatim
|
||||
with `--set KEY "value"`. Push once on vague answers to the required questions.
|
||||
Claude Code only: with the final batch, also ask the ONE operational consent —
|
||||
MCP scope. It is not one of the 12 interview questions; consents ride alongside
|
||||
the bank. The choice: project (recommended — any other repo you open cannot
|
||||
read your brain) vs user (your agent everywhere, but any repo you open can
|
||||
reach it — read and write — and two open sessions contend for the database).
|
||||
Record it with
|
||||
Claude Code and opencode: with the final batch, also ask the ONE operational
|
||||
consent — MCP scope. It is not one of the 12 interview questions; consents ride
|
||||
alongside the bank. On Claude Code the choice: project (recommended — any other
|
||||
repo you open cannot read your brain) vs user (your agent everywhere, but any
|
||||
repo you open can reach it — read and write — and two open sessions contend for
|
||||
the database). On opencode the recommendation INVERTS: user-global is the
|
||||
default and the sharing-safe choice (opencode spawns project-config-defined
|
||||
servers with NO trust prompt, so a committed project entry executes on every
|
||||
collaborator's machine) — offer project only as a deliberate opt-in and state
|
||||
that consequence. Record it with
|
||||
`gbrain bootstrap interview --set MCP_SCOPE <project|user>` BEFORE the
|
||||
read-back, so the confirmation covers it. On Codex, skip this question
|
||||
entirely — the wiring step states the Codex reality instead.
|
||||
@@ -109,9 +117,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
|
||||
@@ -124,6 +138,14 @@ you needed; report the count at the end (it feeds the install-time measurement).
|
||||
on this machine can reach the brain (read and write) through its MCP
|
||||
tools; the off-ramps are `codex mcp remove gbrain` (registration only) or
|
||||
`gbrain bootstrap uninstall` (full teardown).
|
||||
- opencode: writes the MCP entry directly into opencode's JSONC config (no
|
||||
CLI exec needed) and relies on the AGENTS.md protocol, which opencode loads
|
||||
natively — say plainly that opencode gets pull-based context, not per-turn
|
||||
push. Scope follows the recorded MCP_SCOPE answer (user-global default; a
|
||||
project answer writes the committed-candidate `opencode.json` and the CLI
|
||||
prints the sharing warning). Restart opencode after wiring — it reads config
|
||||
at session start. Off-ramps: the entry's `"enabled": false`, or
|
||||
`gbrain bootstrap uninstall`.
|
||||
7. **Private repo.** `gbrain bootstrap repo` — creates a PRIVATE GitHub repo from
|
||||
the workspace, verifies the privacy bit through the API, pushes. If the human
|
||||
started from a repo they created themselves (create-repo-first: an EMPTY private
|
||||
@@ -141,7 +163,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
|
||||
|
||||
@@ -203,7 +227,25 @@ placeholder). Trust the CLI's detection over your own guesses.
|
||||
|
||||
## 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.
|
||||
|
||||
+701
-1
@@ -2,6 +2,706 @@
|
||||
|
||||
All notable changes to GBrain will be documented in this file.
|
||||
|
||||
## [0.46.4.0] - 2026-08-15
|
||||
|
||||
**opencode joins the supported-client roster — at full parity from day one.**
|
||||
(opencode is opencode.ai, SST's terminal agent — not OpenClaw.) Unlike earlier
|
||||
clients that started with a manual recipe, opencode lands with every install
|
||||
lane gbrain has: the paste-in workspace bootstrap, machine-level harness
|
||||
wiring, `gbrain connect`, a claw-test runner, and a real-binary e2e door in
|
||||
CI. Every asserted flag, config shape, and quirk was observed against a
|
||||
pinned install (opencode 1.18.18), recorded in a machine-checked pin
|
||||
document, and exercised against the real binary — including the part that
|
||||
makes opencode special: its keyless anonymous free tier drives MCP tool
|
||||
calls, so the end-to-end proof needs zero secrets.
|
||||
|
||||
### Added
|
||||
- **`gbrain bootstrap hooks --harness opencode`** — workspace-lane MCP
|
||||
registration via direct, comment-preserving JSONC writes (never a CLI
|
||||
exec, works offline). MCP scope is honored with a deliberately INVERTED
|
||||
default: user-global, because opencode spawns project-config servers with
|
||||
no trust prompt; project scope is an explicit opt-in that prints a sharing
|
||||
warning. A structural ownership fingerprint refuses to touch entries
|
||||
gbrain didn't write.
|
||||
- **`gbrain bootstrap harness --harness opencode`** — machine-level remote
|
||||
MCP wiring with an inline bearer written 0600, token rotation across URL
|
||||
changes, content-guarded rollback on failed smoke, `--status` and
|
||||
`--remove`.
|
||||
- **`gbrain connect --agent opencode [--install]`** — env-interpolated
|
||||
bearer (`{env:GBRAIN_REMOTE_TOKEN}`): the token never enters the config
|
||||
file. `--force` replaces a registration whose endpoint moved.
|
||||
- **`gbrain claw-test --agent opencode`** and a split-gated real-binary e2e
|
||||
door in CI: keyless tier (version pin, install + `mcp list` handshake,
|
||||
spawn-gate canary, writer parity, MCP SMOKE on the free tier) plus a paid
|
||||
Anthropic leg that model-gates before spending; npm supply-chain
|
||||
provisioning verifies the actual downloaded tarball bytes against pinned
|
||||
integrities; a schedule-only canary tracks the latest upstream release.
|
||||
- **Docs:** `docs/mcp/OPENCODE.md` install guide,
|
||||
`docs/mcp/OPENCODE-CLI-PIN.md` observation pin (with a verify-time drift
|
||||
guard and a pin-refresh cadence), roster updates across README / INSTALL /
|
||||
bootstrap guides. opencode reads the rendered AGENTS.md pull-protocol
|
||||
contract natively.
|
||||
|
||||
### Changed
|
||||
- The bootstrap config writers (Claude hooks JSON, Codex TOML, opencode
|
||||
JSONC) now share one atomic-write helper; symlinked configs — including
|
||||
dangling dotfile-manager links — survive writes as links.
|
||||
- The door-test family (binary resolution, hermetic child envs, one-shot
|
||||
spawns) extracted into shared factories; the hermes and grok runners were
|
||||
ported onto them, hermes child envs gained the GitHub step-metadata scrub,
|
||||
and the hermes installer pin was refreshed (its nightly door had gone red
|
||||
on upstream installer drift).
|
||||
- A new pin-doc privacy guard asserts every agent pin document ships with
|
||||
placeholder paths and no key material.
|
||||
|
||||
### Fixed
|
||||
- Security and robustness hardening from the pre-landing cross-model review
|
||||
pass: registration verification probes run isolated and time-bounded, and
|
||||
a hung probe is killed instead of abandoned; global config writes
|
||||
reconcile both opencode global filenames under the bootstrap lock; config
|
||||
backups are unique per operation with content-guarded restore; error
|
||||
paths never echo credentials; test-harness child processes drop CI
|
||||
credentials before spawning third-party binaries.
|
||||
|
||||
### To take advantage of v0.46.4.0
|
||||
opencode users: run `gbrain bootstrap hooks --harness opencode` in your
|
||||
brain workspace (or paste the standard bootstrap block into an opencode
|
||||
session). The keyless free tier is enough to verify the wiring end to end —
|
||||
`opencode mcp list` should show `✓ gbrain connected`. Existing installs:
|
||||
nothing changes; this release adds a client, it doesn't modify brain
|
||||
behavior.
|
||||
## [0.46.2.0] - 2026-08-15
|
||||
|
||||
**Dream synthesis now triages before it spends.**
|
||||
([#4152](https://github.com/garrytan/gbrain/issues/4152)) The synthesize
|
||||
phase used to point its most expensive model at every transcript that
|
||||
cleared a yes/no check — on a busy brain that meant an unbounded queue of
|
||||
long frontier-model jobs grinding through logistics and small talk. It is
|
||||
now a two-stage cascade: a cheap scored triage reads every file first, and
|
||||
only what scores above your threshold reaches the synthesis model, which
|
||||
starts from a map of the noteworthy passages instead of hunting through raw
|
||||
transcript.
|
||||
|
||||
### Added
|
||||
- **Scored triage gate.** Every transcript gets a 0–1 salience score,
|
||||
content type, candidate quotes, and entity candidates from the utility-tier
|
||||
model (one call per new file, cached in `dream_verdicts` with the judging
|
||||
model + prompt version — migration v129). The gate
|
||||
(`dream.triage.threshold`, default 0.5) is applied at read time: retune it
|
||||
any time and re-gating costs **zero** new LLM calls. Provider hiccups
|
||||
(truncation, refusal, unparseable output) are never cached as rejections —
|
||||
those files are re-judged next cycle, and an outage reports as "triage
|
||||
degraded", never as "everything scored low".
|
||||
- **`gbrain dream retriage`** — re-score the corpus and reconcile the queued
|
||||
synthesis backlog. `--dry-run` previews from cached scores with zero LLM
|
||||
calls; `--reconcile-queue` cancels queued jobs that score below the gate
|
||||
AND converts jobs stranded in dead per-run queues so the next cycle
|
||||
actually re-submits them; `--audit-rejects <n>` gets a frontier-model
|
||||
second opinion on a sample of rejects (the threshold-calibration loop).
|
||||
Every sweep prints an upfront cost estimate and asks before spending more
|
||||
than a few dollars (`--yes` to skip, `--max-usd` for an estimate-based
|
||||
budget stop that counts every paid call, including unreliable ones — it
|
||||
can overshoot by up to the configured triage concurrency). Guardrails: queues
|
||||
younger than an hour are treated as possibly-live and never touched;
|
||||
`--cancel-unmatched` refuses to run off a truncated or empty corpus scan.
|
||||
- **Triage map in the synthesis prompt.** Passing files hand the synthesis
|
||||
subagent their pre-extracted quotes and entities (verbatim-verified against
|
||||
the chunk text) so it works from signal instead of re-scanning sludge.
|
||||
- **Cost knobs.** `dream.synthesize.max_turns` (default now 16, was a
|
||||
hardcoded 30 — set it back via config if your written-page counts drop;
|
||||
`details.synthesis.avg_turns` shows cap pressure),
|
||||
`dream.triage.max_ms` (per-cycle triage time budget, default 5 min — a big
|
||||
cold corpus triages across a few cycles, with deferred files labeled "not
|
||||
yet triaged", never silently rejected), and an opt-in per-source daily
|
||||
synthesis cap (`dream.synthesize.max_submissions_per_source_per_day`,
|
||||
default off; 200/day is a sane value for busy deployments). The intended
|
||||
pairing is the shipped mid-tier synthesis default — frontier-model
|
||||
overrides are unnecessary with triage doing the reading.
|
||||
|
||||
### Fixed
|
||||
- A run whose submissions were all skipped (cap, already-synthesized) no
|
||||
longer starts the 12-hour cooldown, so the skipped files retry on the next
|
||||
cycle instead of waiting half a day.
|
||||
- Synthesis jobs stranded in a dead per-run queue by a killed cycle are
|
||||
self-healed on the next run (cancelled and re-submitted into the live
|
||||
queue) instead of stalling the phase for the full 35-minute wait.
|
||||
- `gbrain dream retriage --help` (and richer `gbrain dream --help`) now
|
||||
print real usage instead of the generic one-line stub, with no brain
|
||||
configured.
|
||||
|
||||
To take advantage of v0.46.2.0: upgrade and run `gbrain dream` as usual —
|
||||
existing verdicts are re-scored automatically on the next cycle (cheap,
|
||||
utility-tier). If you have a queued synthesis backlog, run
|
||||
`gbrain dream retriage --dry-run` to preview, then
|
||||
`gbrain dream retriage --reconcile-queue` to drain it for pennies. Tune
|
||||
`dream.triage.threshold` freely; re-gating is free.
|
||||
|
||||
## [0.46.1.0] - 2026-08-15
|
||||
|
||||
**A stuck job can no longer take down your whole worker.** Field reports from
|
||||
a production deployment ([#5](https://github.com/garrytan-agents/gbrain/issues/5),
|
||||
[#6](https://github.com/garrytan-agents/gbrain/issues/6)) showed two
|
||||
compounding failure modes: a handler that ignored its abort signal could only
|
||||
be "force-evicted" (abandoned but still running, still holding connections),
|
||||
and abandoned probe/renewal queries starved the connection pool until the
|
||||
worker killed itself with a misleading "DB unreachable" — while the database
|
||||
sat at a fraction of capacity. This release fixes the starvation class and
|
||||
adds real per-job blast-radius control.
|
||||
|
||||
### Added
|
||||
- **`gbrain jobs work --job-isolation process`** (also
|
||||
`gbrain jobs supervisor --job-isolation process`, env
|
||||
`GBRAIN_JOB_ISOLATION`): each claimed job runs in its own child process.
|
||||
A stuck handler is group-SIGKILLed for real instead of abandoned, a crash
|
||||
or memory blowup takes one job instead of all N, and the OS reclaims every
|
||||
leaked resource when the child dies. The worker keeps claiming, renewing,
|
||||
and recording; handler-error semantics (unrecoverable → dead, rate-lease →
|
||||
no attempt burned, backoff otherwise) are preserved across the boundary.
|
||||
Worker shutdown gives children the drain window to finish and report — a
|
||||
routine deploy never burns a job attempt. Recommended for long-running
|
||||
LLM-bound handlers; see the new section in `docs/guides/minions-deployment.md`.
|
||||
- **Health-probe verdicts that name the failing layer.** When the worker's DB
|
||||
probe fails, it now disambiguates via the direct session lane and says
|
||||
`pool_starved` ("server IS reachable; the fault is in the
|
||||
transaction-pooler path") or `server_unreachable` — instead of the blanket
|
||||
"DB unreachable" that historically sent operators debugging database
|
||||
capacity while the real fault was client-side. A startup warning also makes
|
||||
single-pool mode (direct-lane kill switch) loud instead of silent, and
|
||||
`docs/guides/queue-operations-runbook.md` gains a verdict-interpretation
|
||||
table.
|
||||
- `GBRAIN_POOL_MAX_LIFETIME_S`: explicit client-pool connection max-lifetime
|
||||
knob (0 disables; default stays the per-connection 30–60min jitter).
|
||||
|
||||
### Fixed
|
||||
- **Timed-out DB probes and lock renewals are now cancelled, not abandoned.**
|
||||
Every place that raced a query against a timer (health probe, minion lock
|
||||
renewal, cycle-drain renewal, submit-time queue probes, DB-lock refresh)
|
||||
previously let the losing query keep running on a checked-out connection —
|
||||
under pool exhaustion each abandoned racer held a slot and made the
|
||||
exhaustion worse, starving the lock heartbeat first. All five sites now
|
||||
abort the query via its cancellation signal so the slot is released.
|
||||
- Long-running maintenance holds (index rebuilds, non-transactional
|
||||
migrations, backfill write batches) now reserve from the direct session
|
||||
lane instead of pinning the worker's shared pool — capped so reserved
|
||||
holds always leave a direct-lane slot for the claim/renewal heartbeats,
|
||||
and falling back to the previous behavior when the direct lane is
|
||||
unavailable.
|
||||
|
||||
Full operational detail: `docs/guides/minions-deployment.md` (isolation
|
||||
sizing: connections, memory, spawn cost) and
|
||||
`docs/guides/queue-operations-runbook.md` (probe verdicts).
|
||||
|
||||
## [0.46.0.0] - 2026-08-14
|
||||
|
||||
**Your other agents' sessions become brain knowledge.** Until now only Claude
|
||||
Code sessions flowed into the brain automatically; every Codex rollout,
|
||||
OpenClaw session, and Hermes conversation on your disk — often years of
|
||||
decisions — was invisible. `gbrain transcripts ingest` imports them all as
|
||||
readable conversation pages with provenance back to the exact session file,
|
||||
and the facts pipeline makes them answer "what did I decide about X, in
|
||||
whichever agent I said it" as one query. Consumer chat exports (ChatGPT and
|
||||
Claude.ai `conversations.json`) import through the same door.
|
||||
|
||||
- **One command, six formats.** `gbrain transcripts ingest <path-or-glob>`
|
||||
auto-detects Claude Code JSONL, Codex rollouts, OpenClaw sessions, the
|
||||
Hermes SQLite store (read from a lock-safe copy), and extracted
|
||||
ChatGPT/Claude.ai exports. No arguments shows what it WOULD import across
|
||||
your harness directories; `--all` imports the discovered set;
|
||||
`gbrain transcripts status` shows the found-vs-imported gap per harness.
|
||||
- **Safe by default.** Secrets are redacted before anything is written
|
||||
(bodies, titles, speaker labels, and session metadata; plus your
|
||||
`harvest-private-patterns.txt` rules), message content that mimics
|
||||
conversation formatting cannot forge speakers or timestamps, and imports
|
||||
are a readable text-turn archive by design — tool payloads and thinking
|
||||
blocks never land in pages (one-line placeholders mark where they
|
||||
happened). Embedding is off by default for bulk backfills
|
||||
(opt in with the embed flag, or run the embed backfill later).
|
||||
- **Free to re-run.** Unchanged sessions skip on content hash; long sessions
|
||||
split into searchable parts that reconcile themselves when a session
|
||||
shrinks; interrupted runs converge on the next pass, healing any half-done
|
||||
writes. `--since last` resumes from the previous complete run and never
|
||||
advances past files it could not fully read.
|
||||
- **Facts on demand.** `--facts` extracts through the shipped
|
||||
conversation-facts pipeline under a budget cap; imported pages also flow
|
||||
into the existing scheduled backfill when that cycle phase is enabled.
|
||||
|
||||
### Added
|
||||
- `gbrain transcripts ingest` and `gbrain transcripts status` subcommands
|
||||
(engine-free `--help`), with discovery mode, `--all`, `--dry-run`,
|
||||
`--format`, `--limit`, `--since <iso|last>`, `--source-id`, `--facts`,
|
||||
`--max-cost-usd`, `--embed`, `--json`, `--quiet`.
|
||||
- Transcript-adapter seam at `src/core/transcripts/` (session-granular
|
||||
contract with per-file diagnostics and drift alarms; dated spec targets per
|
||||
host format) and adapters for Codex, OpenClaw, Hermes, ChatGPT export, and
|
||||
Claude.ai export; the shipped Claude Code parser gains an additive
|
||||
timestamp-preserving mode, regression-pinned for the hook lane.
|
||||
- Batch `slugs` selector on the conversation-facts extraction core (one
|
||||
invocation per import run; an empty list is a no-op, never a full-corpus
|
||||
walk).
|
||||
- Write-back fidelity e2e through the raw adapter path (gold-extractor
|
||||
seam), pinning cross-harness continuity in one source.
|
||||
|
||||
### Changed
|
||||
- `skills/conversation-archive` now routes the covered formats to the native
|
||||
importer and states the native-vs-manual privacy delta.
|
||||
- The fixture-privacy gate also scans the new transcript fixture corpus.
|
||||
|
||||
### Fixed
|
||||
- PGLite `putRawData` now detects a missing page like the Postgres engine
|
||||
(integrity failures abort instead of silently no-opping).
|
||||
|
||||
### To take advantage of v0.46.0.0
|
||||
Upgrade, then run `gbrain transcripts ingest` with no arguments to see every
|
||||
importable session log on the machine, and `gbrain transcripts ingest --all`
|
||||
to import them. Unzip consumer exports first and pass the extracted
|
||||
`conversations.json`. On PGLite, stop `gbrain serve` for the import (the
|
||||
single-writer lock error names the PID if you forget). Run
|
||||
`gbrain transcripts status` any time to see what's still waiting.
|
||||
## [0.45.20.0] - 2026-08-14
|
||||
|
||||
**Grok Build joins the supported-client roster.** xAI's `grok` CLI can now wire a gbrain brain in one command, and — like Hermes before it — the install path is proven against the real binary, not written from docs: every asserted flag, config shape, and exit-code quirk was observed against a pinned Grok Build install, recorded in a machine-checked pin document, and exercised by a real-binary e2e door that CI can run.
|
||||
|
||||
### Added
|
||||
|
||||
- **Grok Build install support.** `grok mcp add gbrain -- gbrain serve --surface verbs` wires the seven-verb memory surface into xAI's coding agent; [docs/mcp/GROK.md](docs/mcp/GROK.md) carries the full guide — registration, direct TOML config, the trust-gated vendor-config fallback (an existing Claude Code registration may already work), verification via `grok mcp doctor` (the honest probe: the add itself is lazy and always exits 0), headless auth, model pinning, auto-update pinning for reproducible environments, cron pairing, and troubleshooting (including the colliding community `grok` binary and where Grok actually discovers skills). `INSTALL_FOR_AGENTS.md` gains the matching "If you are Grok Build" block; the guide is honest that this is the brain-only install — the `gbrain bootstrap` personal-agent path doesn't support Grok yet.
|
||||
- **`gbrain claw-test --live --agent grok`.** Grok is the third registered agent runner, so guide-following friction runs and `gbrain friction diff --base <hermes-run> --compare <grok-run>` work out of the box. The runner records a version preamble in every transcript (a mis-bound community binary is diagnosable after the fact) and warns loudly when the operator's `~/.claude.json` registers gbrain — Grok reads vendor MCP configs for trusted folders, a contamination channel no other supported agent has.
|
||||
- **A real-binary "door" e2e for Grok** (`test/e2e/install-real-grok.serial.test.ts`), split-gated so the free tier needs no API key: version pin, the documented registration shape end-to-end, the seven-verb handshake proven keyless, a vendor-fallback provenance guard, and the direct-TOML surface all run with just the binary; the paid recall smoke (a per-run nonce fact, web search disabled) additionally needs `XAI_API_KEY`. A label-gated `grok-door` CI job provisions the pinned npm package (wrapper AND per-platform payload integrities pre-checked against the pin doc), banks the keyless coverage before any secret is required, and refuses green on a silently-skipped paid tier.
|
||||
- **`docs/mcp/GROK-CLI-PIN.md`** — the observed-behavior pin (config schema verbatim, lazy-add semantics, honest doctor discriminator, keyless TUI sign-in behavior, volatile-path inventory, supported-version policy) with machine-readable stamps enforced against the CI workflow by a new `check-grok-pin` verify guard, which fails closed if the pin doc ever disappears out from under the door job.
|
||||
- **A `grok-install` DX scenario** (`scripts/dx-explore.ts`) drives the REAL interactive Grok TUI through the brain-only install under a PTY, with a sign-in-wall early-stop so keyless runs record the friction in seconds instead of pasting into a login screen for the full wall clock.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Agent runners share their detection/env plumbing.** The byte-identical binary-resolution and env-filtering bodies moved out of the per-agent runners into `agent-runner.ts` (`detectBinary`/`filterAllowlistEnv`), shrinking every future runner; binary resolution never passes through a shell anymore. The live-lane Grok runner forwards only Grok's own credentials — the operator's Anthropic/OpenAI keys never reach a third-party binary.
|
||||
- **PTY transcripts are structurally redacted at every write site.** Provider-key values are replaced in every artifact — including the live screen mirror that outlives interrupted runs — with a hard-failing independent check behind the redaction; a secret split across output bursts can no longer be reassembled from the frame log. `--keyless` now genuinely drops provider keys in all install scenarios, the leak check never touches files that predate the run, and the PTY hot loops strip bounded windows instead of the whole buffer (repaint-heavy TUIs were making every poll quadratic).
|
||||
- **The hermes-door CI job got the same hardening sweep:** checkout token persistence off, and the door's full test output is preserved as evidence even for the failure class that previously left no trace.
|
||||
|
||||
To take advantage of v0.45.20.0: nothing changes for existing installs — this release adds a client, it doesn't modify brain behavior. Grok Build users: follow [docs/mcp/GROK.md](docs/mcp/GROK.md) (two commands: register, then `grok mcp doctor gbrain` to verify the seven-verb handshake). Maintainers enabling the paid CI lane: create the `XAI_API_KEY` repo secret, then land the follow-up that adds the schedule and canary legs.
|
||||
## [0.45.19.0] - 2026-08-15
|
||||
|
||||
**The interactive `gbrain init` pickers are now tested on a real terminal — and the repo carries one PTY layer instead of two.** The first thing every new user touches (the embedding provider picker and the search-mode picker) previously had no true-TTY coverage; the comments that claimed otherwise pointed at a harness nothing ever called.
|
||||
|
||||
### Added
|
||||
- A real-PTY test for the interactive init flow (`test/init-picker-pty.serial.test.ts`). It drives both pickers under a true pseudo-terminal, proves typed input actually lands (non-default selections plus bounded response times, so dead input can never pass through the pickers' silent defaults), covers the Ctrl-D/EOF fallback, and runs in the required serial CI lane. Fully hermetic: temp-root home, no provider keys visible, child process reaped even on failed assertions.
|
||||
|
||||
### Removed
|
||||
- The unused second PTY harness (`test/helpers/cli-pty-runner.ts` plus its self-test, ~657 lines). Its spawn path never gained a caller across ~20 minor versions. The real-terminal layer is consolidated on `test/helpers/tty-harness.ts` (Bun's built-in `terminal:` spawn — no native modules).
|
||||
|
||||
### Fixed
|
||||
- Three test-file comments that claimed interactive-picker coverage existed where it didn't — two deferred to each other in a circle, one described a piped-stdin test as PTY-based. All three now point at the real coverage.
|
||||
|
||||
### Changed
|
||||
- `docs/TESTING.md` gains a four-tier decision table for TTY and interactive-CLI testing (injected `isTTY` → piped stdin → real-PTY serial test → DX-exploration instrument), including the rule that CI-required interactive tests live in the serial lane. `docs/architecture/KEY_FILES.md` now describes the surviving harness accurately, and related project docs and the stale shard-weight entry were trued up to match.
|
||||
|
||||
## [0.45.18.0] - 2026-08-15
|
||||
|
||||
**Today's agent spend now reads correctly at every hour, in every timezone.** The admin spend endpoint computed "today" against a naive timestamp that each database session reinterpreted in its own timezone — on any non-UTC session (a PGLite brain following the host clock, a timezone-configured Postgres role), the day boundary shifted by the offset and every evening's spend silently underreported as 0. The boundary is now a UTC instant, independent of session timezone, pinned by a regression test that exercises sessions 12 hours either side of UTC at any wall-clock hour.
|
||||
|
||||
The same class also made the new test-suite snapshot fixture time-of-day flaky: the snapshot bakes the build machine's timezone into the restored cluster, so snapshot-restored engines ran sessions in the builder's zone while cold-init engines followed the running process. Restored engines now re-pin their session to the runtime zone (existing tarballs heal without a rebuild), the snapshot builder pins UTC so tarballs are deterministic across hosts, and a parity test asserts cold and snapshot engines agree on their UTC offset.
|
||||
|
||||
### Fixed
|
||||
- `/admin/api/agents/spend`: `spent_cents_today` no longer underreports on non-UTC sessions (UTC-instant day boundary).
|
||||
- Snapshot-restored PGLite engines behave identically to cold-init engines regardless of the machine that built the tarball.
|
||||
|
||||
## [0.45.17.0] - 2026-08-15
|
||||
|
||||
**A test run can no longer silently touch a real brain.** `gbrain init` writes your
|
||||
database URL into `~/.gbrain/.env`; anyone who had that sourced and ran a bare
|
||||
`bun test` in the repo was one destructive fixture away from their own data
|
||||
(#3485 — it has happened). Four independent layers now stand in the way, and
|
||||
each one fails loudly instead of silently skipping:
|
||||
|
||||
- **The run refuses to start.** A test preload (registered first in
|
||||
`bunfig.toml`) hard-fails any `bun test` invocation while `DATABASE_URL` or
|
||||
`GBRAIN_DATABASE_URL` is ambient, with instructions — it never silently
|
||||
unsets, because a silent unset would turn database-gated e2e tests into
|
||||
green skips. The e2e and heavy lanes opt in at their own boundary;
|
||||
the unit and slow lanes strip the variables at theirs, so
|
||||
`bun run test:full` with a database URL exported still reaches its e2e leg.
|
||||
- **Destructive tests check the database name.** Every test that runs
|
||||
destructive SQL against the ambient URL now calls a shared name floor
|
||||
(moved to a leaf module so unit-directory tests can use it too): the
|
||||
database name must carry "test" as a word segment, or be opted in
|
||||
explicitly, one-shot. (One suite keeps its own equivalent inline floor,
|
||||
pinned by the coverage gate.) This adopts the patch contributed in #3485 by
|
||||
@cheRoma — thank you — extended to two newer files the original audit
|
||||
predates and one raw-client suite it couldn't see.
|
||||
- **Shell lanes get the same floor.** The heavy-test scripts (schema drops,
|
||||
parallel syncs, migration replays) share a floor that checks BOTH database
|
||||
URL variables and strips query strings before extracting the name, so a
|
||||
`?host=/tmp/test-sockets` parameter can't smuggle a test-shaped segment
|
||||
past it.
|
||||
- **A repo-wide static gate keeps it that way.** A scanner walks every test
|
||||
file bun would collect (all naming patterns, fixtures included), flags any
|
||||
file that reads an ambient database URL, opens a connection, and runs
|
||||
destructive SQL without a guard — and its own classifiers are pinned by
|
||||
positive controls so the gate can never rot into passing vacuously.
|
||||
|
||||
### Added
|
||||
- Test-run guard preload (`test/helpers/database-url-guard-preload.ts`) with
|
||||
subprocess tests covering every branch: both variables, both-set, override,
|
||||
strict override value, empty-string, and clean runs.
|
||||
- Shared destructive-SQL name floor `test/helpers/db-guard.ts` (re-exported
|
||||
from `test/e2e/helpers.ts` for existing call sites) and shell twin
|
||||
`tests/heavy/_db_floor.sh`.
|
||||
- Repo-wide destructive-SQL coverage gate `test/db-guard-coverage.test.ts`
|
||||
with classifier self-tests and positive controls.
|
||||
|
||||
### Fixed
|
||||
- Ten destructive test files now verify the database name before connecting
|
||||
(#3485; patch by @cheRoma, extended).
|
||||
- The heavy lane's fixture builder, sync-lock, upgrade-matrix, and wallclock
|
||||
scripts refuse non-test-shaped database names instead of operating on
|
||||
whatever the environment points at.
|
||||
- The phantom-redirect engine-parity test's Postgres arm is now carried by
|
||||
the e2e lane and CI's parity job — previously no lane could reach it.
|
||||
|
||||
### To take advantage of v0.45.17.0
|
||||
|
||||
Nothing to configure. If a bare `bun test` now refuses to start, the message
|
||||
tells you exactly why and what to do — usually just unset the database URL
|
||||
(unit tests need no database) or use `bun run test:e2e`, which opts in at its
|
||||
own boundary. If your e2e database has a non-test-shaped name, opt in one-shot
|
||||
with `GBRAIN_E2E_ALLOW_DB=<name>` rather than exporting it in your shell
|
||||
profile — a permanent export would disarm the guard for exactly the database
|
||||
it protects.
|
||||
|
||||
## [0.45.16.0] - 2026-08-14
|
||||
|
||||
**Fix wave W0: the verified-bug hotfix pass of the code-smell series.** A 10-auditor sweep of the codebase produced 122 findings; the top claims were adversarially verified, and this release fixes every verified live bug — the ones that survived the skeptic pass. Long-running brains get the biggest wins: background cycles can no longer silently run twice, dead background jobs no longer strand their parents, and image search no longer silently degrades after re-embedding. Developers get a test suite that runs 10x faster.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **The background-cycle lock is now actually refreshed while a cycle runs.** Long phases (synthesis, pattern extraction, consolidation — up to 35-minute waits) previously outlived the 5-minute lock TTL with no heartbeat, so a second cycle could start against the same brain and both would write concurrently — duplicated LLM spend and racy writes on Postgres/Supabase brains. A dedicated refresher now heartbeats the lock, every refresh and release is fenced to the exact acquisition (a recycled PID or a superseded run can never touch a successor's lock — including the PGLite file lock, which is no longer rewritten after a detected steal), and a run that loses its lock stops at the next phase boundary with a structured `lock_stolen` report instead of compounding. The job supervisor treats a fenced miss as certain loss and exits for a clean restart.
|
||||
- **Background jobs that die from repeated stalls now notify and unblock their waiting parents.** Previously an aggregator parent whose child was dead-lettered by the stall sweep waited forever; a self-healing sweep also releases parents stranded before the upgrade.
|
||||
- **Retried jobs no longer burn their wall-clock budget while waiting in backoff.** Every automatic re-run path — and every parent-unblock path — resets the per-attempt clock, so exponential-backoff retries and long-waiting aggregators aren't dead-lettered before executing a line.
|
||||
- **Re-embedding no longer flips image chunks to text.** `gbrain embed --stale` (including the autopilot path) preserved every chunk field except `modality`, silently zeroing image retrieval until the next full import. One shared carry list now serves every re-embed path.
|
||||
- **A failed first sync no longer kills the MCP server.** Import preflight failures (missing embedding credentials, unreadable target) now surface as normal tool errors instead of terminating the serving process mid-call.
|
||||
- **`gbrain lint --fix` reports the true fix count** (it previously scanned everything twice and reported "0 auto-fixed" after fixing issues) and walks the tree once.
|
||||
- **The PGLite repair and re-init confirmation prompts can no longer hang forever** on closed or piped stdin: EOF declines safely, and prompts write to stderr so `--json` output stays clean.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`bun run test` is ~10x faster** (measured: a full parallel suite run dropped from ~82 to ~8 minutes). The PGLite schema snapshot is now default-on for the everyday test loop, rebuilt automatically when migrations or the pinned embedding shape change, concurrency-safe across parallel shards and workspaces, and refused on any shape mismatch so a wrong fixture can never poison the suite.
|
||||
- **CI guards now prove they can fail.** A guard registry classifies all 45 check scripts; self-tested scanner guards run against known-bad fixtures on every verify (the registry tracks fixture coverage for the rest), so a guard whose pattern rots into a permanently-green no-op fails the build instead of masquerading as coverage. Two such rotted patterns were found and fixed in the process, along with three guards that were wired into a registry nobody ran.
|
||||
|
||||
To take advantage of v0.45.16.0: upgrade and restart any long-running `gbrain serve`, autopilot, or jobs supervisor/worker daemon so the fenced lock refresh and job-reaper fixes take effect. If you run image search, run `gbrain backfill modality` once after upgrading to restore any image chunks a prior re-embed flipped to text (`gbrain doctor` surfaces the affected count and the exact command). No schema migration and no config changes are required.
|
||||
## [0.45.15.0] - 2026-08-14
|
||||
|
||||
**The queue that drains itself: three background-jobs fixes reported from a downstream agent deployment (upstream issues #2, #3, #4).** A brain whose autopilot cycle stalled mid-run could accumulate byte-identical queued cycles forever while every long job queued before an upgrade died minutes in — and the operator diagnosing it couldn't even find the worker entry point, because `gbrain jobs --help` printed a one-line stub. All three failure modes are closed, and the queue now tells you when it's holding work back.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Stalled cycles no longer breed duplicates.** Autopilot dispatch now uses a single-flight guard (`maxPending`) that counts waiting jobs AND actively-running jobs with a live lock, scoped per source — so a cycle stuck in `active` suppresses re-dispatch instead of minting a new duplicate every tick. A job whose worker died stops counting the moment its lock lapses, so a dead worker can never silently freeze dispatch: the fresh waiting row keeps feeding the existing wedge detectors. Applies to per-source cycles, the legacy single-source path, and brain-wide maintenance.
|
||||
- **Long jobs queued before an upgrade get their real budget.** Handler wall-clock budgets now also resolve at claim time (not just at submit), so rows inserted with no budget — including anything queued on an older version — run with their documented allowance instead of being dead-lettered by the minutes-scale default. Migration v128 backfills budgets for everything still in flight and cancels the duplicate cycle backlog (newest per source survives; manually submitted cycles without a ticker-style idempotency key are never touched; cancelled rows are kept for audit).
|
||||
- **`gbrain jobs --help` prints the real surface.** The full subcommand list plus dedicated help for `work`, `supervisor`, `submit`, `watch`, and `prune` — engine-free, and a help flag after a subcommand can no longer fall through and start a real worker daemon.
|
||||
|
||||
### Added
|
||||
|
||||
- **`gbrain jobs stats` shows suppressed dispatch.** A `Backpressure (24h)` line reports submissions coalesced onto in-flight jobs, plus a hint naming the specific in-flight job holding a queue-empty name back — the visibility that was missing when "nothing queued, nothing completing" was the only symptom.
|
||||
- **`gbrain jobs get <id>` shows the effective wall-clock budget** — the stamped timeout and deadline, or which default applies and when it kicks in.
|
||||
- Autopilot cycle dispatch tells the truth: a submission that coalesced onto an existing job reports `dispatch_coalesced` (and `coalesced: true` in `jobs submit`'s JSON output) instead of claiming a dispatch that never inserted a row.
|
||||
|
||||
To take advantage of v0.45.15.0: upgrade and run any gbrain command — migration v128 applies automatically, backfilling budgets for queued long jobs and clearing any duplicate cycle backlog. If a queue looked wedged before, `gbrain jobs stats` now names the in-flight job to inspect and `gbrain jobs work --help` documents the worker daemon flags end to end.
|
||||
|
||||
## [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.
|
||||
@@ -341,7 +1041,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
|
||||
|
||||
|
||||
@@ -70,7 +70,11 @@ Per-file detail is in `docs/architecture/KEY_FILES.md`.
|
||||
- **Engine-live paths avoid runtime dynamic `import()` for helper dependencies.** In
|
||||
`src/core/pglite-engine.ts`, `src/core/postgres-engine.ts`, and
|
||||
`src/core/migrate.ts`, dependencies previously reached through runtime dynamic
|
||||
imports use static top-level imports. The only current dynamic-`import()` exceptions
|
||||
imports use static top-level imports. Besides the snapshot loader's lazy
|
||||
`require()` cluster in `pglite-engine.ts:tryLoadSnapshot` (fs/crypto/
|
||||
migrate/pglite-schema + one gateway shape lookup — lazy so production
|
||||
builds without the test-fixture path don't eager-load; the guard now
|
||||
matches `require()` calls too), the only dynamic-`import()` exceptions
|
||||
are the four `ai/gateway.ts` lookups in both engines'
|
||||
`initSchema()` and `_upsertChunksOnce()` methods; each remains lazy inside a
|
||||
local `try/catch` because the gateway has a large provider/config closure and,
|
||||
@@ -506,7 +510,7 @@ 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.8.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. |
|
||||
|
||||
|
||||
+25
-5
@@ -92,11 +92,11 @@ The canonical reference for test tiers, isolation rules, timing, and the E2E
|
||||
lifecycle is [`docs/TESTING.md`](docs/TESTING.md). The short version:
|
||||
|
||||
```bash
|
||||
# Inner edit loop (~85s on a Mac dev box)
|
||||
bun run test # parallel 4-shard fan-out (memory-adaptive) + serial post-pass
|
||||
# Inner edit loop (~8min full suite on a Mac dev box; single files in seconds)
|
||||
bun run test # parallel 4-shard fan-out (memory-adaptive) + serial post-pass; PGLite snapshot default-on
|
||||
bun test test/markdown.test.ts # specific unit test
|
||||
|
||||
# Pre-push gate (19+ parallel checks + typecheck)
|
||||
# Pre-push gate (40+ parallel checks + typecheck)
|
||||
bun run verify
|
||||
|
||||
# Pre-merge sanity (everything CI runs)
|
||||
@@ -115,7 +115,21 @@ DATABASE_URL=postgresql://postgres:postgres@localhost:5434/gbrain_test bun run t
|
||||
DATABASE_URL=postgresql://... bun run test:e2e
|
||||
```
|
||||
|
||||
Heads-up: a bare `bun test` refuses to start while `DATABASE_URL` or
|
||||
`GBRAIN_DATABASE_URL` is set in your environment — some tests run destructive
|
||||
SQL against whatever those URLs point at. Unset the variable for unit runs
|
||||
(they need no database) or use the wrappers: the unit/slow runners strip the
|
||||
variables at their boundary, and `bun run test:e2e` opts in at its own. The
|
||||
refusal message walks you through it; details in
|
||||
[`docs/TESTING.md`](docs/TESTING.md) ("Database-URL run guard"). If you point
|
||||
`bun run test:e2e` at your own Postgres or Supabase, a second floor applies:
|
||||
the database name must carry "test" as a word segment (like `gbrain_test`
|
||||
above) or destructive tests refuse to run — opt a differently-named database
|
||||
in one-shot with `GBRAIN_E2E_ALLOW_DB=<name>`.
|
||||
|
||||
Use `bun run verify` before pushing. It runs 19+ guard checks in parallel
|
||||
|
||||
Use `bun run verify` before pushing. It runs 40+ guard checks in parallel
|
||||
(`scripts/run-verify-parallel.sh`), including: banned fork-name leaks
|
||||
(`scripts/check-privacy.sh`), `JSON.stringify(x)::jsonb` interpolation
|
||||
patterns (`scripts/check-jsonb-pattern.sh`), `\r` progress bleed to stdout
|
||||
@@ -124,8 +138,14 @@ patterns (`scripts/check-jsonb-pattern.sh`), `\r` progress bleed to stdout
|
||||
loop" below), silent fallback to recursive chunking in the compiled binary
|
||||
(`scripts/check-wasm-embedded.sh`), stale admin-dashboard build artifacts
|
||||
(`scripts/check-admin-build.sh`), resolver drift on bundled skills
|
||||
(`bun run check:resolver`), and typecheck. `bun run check:all` runs the full
|
||||
historical sweep including the trailing-newline and exports-count checks.
|
||||
(`bun run check:resolver`), and typecheck. The guard REGISTRY is
|
||||
`scripts/guards-manifest.tsv`, and `scripts/guard-self-test.sh` (also in
|
||||
`verify`) proves each self-tested scanner guard (`selftest=yes` in the
|
||||
manifest; coverage ratchets up from the `todo` rows) can actually fail by
|
||||
running it against known-bad fixtures — a new `scripts/check-*` guard must be
|
||||
registered in the manifest or the build fails. There is no `check:all` script; the
|
||||
trailing-newline, exports-count, and no-legacy-getconnection checks run in
|
||||
`verify` with everything else.
|
||||
|
||||
### Writing tests that survive the parallel loop
|
||||
|
||||
|
||||
+42
-3
@@ -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,44 @@ 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).
|
||||
|
||||
**If you are Grok Build** (xAI's `grok` CLI): register gbrain as your MCP server:
|
||||
|
||||
```bash
|
||||
grok mcp add gbrain -e "GBRAIN_HOME=$HOME" -- gbrain serve --surface verbs
|
||||
```
|
||||
|
||||
The add is lazy (exit 0 without connecting) — verify with
|
||||
`grok mcp doctor gbrain`, which spawns the server and must report
|
||||
`7 tools discovered`. This is the brain-only install; the `gbrain bootstrap`
|
||||
personal-agent path does not support Grok yet (Claude Code, Codex, and opencode only).
|
||||
Verified against Grok Build v1.0.4. Full reference:
|
||||
[docs/mcp/GROK.md](docs/mcp/GROK.md).
|
||||
|
||||
**If you are opencode** (the SST terminal agent, opencode.ai — not OpenClaw):
|
||||
you are a bootstrap-supported harness — for the full persistent-personal-agent
|
||||
install, follow `BOOTSTRAP_FOR_AGENTS.md` instead of this page. For the
|
||||
brain-only MCP registration:
|
||||
|
||||
```bash
|
||||
opencode mcp add gbrain --env GBRAIN_HOME=$HOME -- gbrain serve --surface verbs
|
||||
```
|
||||
|
||||
The add is lazy (exit 0 without connecting) — verify with `opencode mcp list`,
|
||||
which spawns the server and must show `✓ gbrain connected` (the exit code is 0
|
||||
even on failure; read the output). Restart opencode afterwards — it reads
|
||||
config at session start. Verified against opencode v1.18.18. Full reference:
|
||||
[docs/mcp/OPENCODE.md](docs/mcp/OPENCODE.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
|
||||
|
||||
@@ -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**: 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. 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, cloud sandboxes, 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.
|
||||
|
||||
@@ -170,6 +172,10 @@ 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).
|
||||
- **[Grok Build](docs/mcp/GROK.md)** — `grok mcp add gbrain -e "GBRAIN_HOME=$HOME" -- gbrain serve --surface verbs`. The add is lazy (exit 0 without connecting) — verify with `grok mcp doctor gbrain`, which spawns the server and reports `7 tools discovered`. Verified against Grok Build v1.0.4.
|
||||
- **[opencode](docs/mcp/OPENCODE.md)** (opencode.ai / SST — not OpenClaw) — `opencode mcp add gbrain --env GBRAIN_HOME=$HOME -- gbrain serve --surface verbs`, or let `gbrain bootstrap hooks --harness opencode` write the config for you (opencode is a bootstrap-supported harness — it reads AGENTS.md natively). The add is lazy — verify with `opencode mcp list`, which spawns the server (`✓ gbrain connected`). Remote: `gbrain connect https://your-host/mcp --token gbrain_xxx --agent opencode [--install]` — the config stores only the `{env:GBRAIN_REMOTE_TOKEN}` interpolation. Verified against opencode v1.18.18.
|
||||
- **[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.
|
||||
@@ -230,6 +236,21 @@ curl -X POST https://your-brain/ingest \
|
||||
For mobile capture, the inbox folder source picks up anything dropped into
|
||||
`~/.gbrain/inbox/` from iOS Shortcuts / AirDrop / Drafts / Finder.
|
||||
|
||||
Your other agents' histories import in one command. `gbrain transcripts ingest`
|
||||
parses agent session logs (Claude Code, Codex, OpenClaw, Hermes) and extracted
|
||||
consumer chat exports (ChatGPT / Claude.ai `conversations.json`) into readable
|
||||
conversation pages with provenance back to the exact session file. Secrets are
|
||||
scrubbed from message bodies, titles, speakers, and session metadata before
|
||||
anything is written, embedding is off by default for bulk backfills, and
|
||||
re-runs are free — unchanged sessions skip on content hash:
|
||||
|
||||
```bash
|
||||
gbrain transcripts ingest # discover importable session logs
|
||||
gbrain transcripts ingest --all # import everything discovered
|
||||
gbrain transcripts ingest ~/Downloads/conversations.json # consumer export (unzip first)
|
||||
gbrain transcripts status # found vs imported, per harness
|
||||
```
|
||||
|
||||
Third-party skillpacks can ship custom ingestion sources (Granola, Linear,
|
||||
voice, OCR) against the versioned `IngestionSource` contract at
|
||||
`gbrain/ingestion`. See [`docs/skillpack-anatomy.md`](docs/skillpack-anatomy.md).
|
||||
@@ -291,7 +312,7 @@ The whole loop is described in [`docs/architecture/topologies.md`](docs/architec
|
||||
|
||||
**Self-wiring knowledge graph.** Every `put_page` extracts entity refs from markdown/wikilinks/typed-link syntax and writes edges with zero LLM calls. Typed edges (`attended`, `works_at`, `invested_in`, `founded`, `advises`, `mentions`, …). Multi-hop traversal via `gbrain graph-query`. The graph is what produces the +31.4 P@5 lift over vector-only RAG. **Obsidian-style vaults:** bare `[[note-name]]` wikilinks that point across folders — you wrote `[[struktura]]` but the page lives at `projects/struktura.md` — resolve by basename once you opt in with `gbrain config set link_resolution.global_basename true`. Off by default; `gbrain doctor` tells you how many edges you'd gain before you flip it. See [migrating an Obsidian vault](INSTALL_FOR_AGENTS.md#step-45-wire-the-knowledge-graph).
|
||||
|
||||
**Job queue (Minions).** BullMQ-shaped, Postgres-native job queue. Durable subagents (LLM tool loops that survive crashes via two-phase pending→done persistence), shell jobs with audit, child jobs with cascading timeouts, rate leases for outbound providers, attachments via S3/Supabase storage. Replaces "spawn subagent as fire-and-forget Promise" with something that recovers from anything.
|
||||
**Job queue (Minions).** BullMQ-shaped, Postgres-native job queue. Durable subagents (LLM tool loops that survive crashes via two-phase pending→done persistence), shell jobs with audit, child jobs with cascading timeouts, rate leases for outbound providers, attachments via S3/Supabase storage. Opt-in per-job process isolation (`gbrain jobs work --job-isolation process`) runs each claimed job in its own SIGKILL-able child process, so a stuck handler dies for real and a crash takes one job instead of the whole worker; when the worker's DB health probe fails, it names the failing layer (`pool_starved` vs `server_unreachable`) instead of a blanket "DB unreachable". Sizing and rollout guidance in [`docs/guides/minions-deployment.md`](docs/guides/minions-deployment.md); probe-verdict triage in [`docs/guides/queue-operations-runbook.md`](docs/guides/queue-operations-runbook.md). Replaces "spawn subagent as fire-and-forget Promise" with something that recovers from anything.
|
||||
|
||||
**Non-English brains (FTS language config).** The Postgres full-text search tokenizer is configurable via `GBRAIN_FTS_LANGUAGE`. Defaults to `english`. Set it to any text-search configuration that exists in your Postgres instance:
|
||||
|
||||
@@ -462,7 +483,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
|
||||
|
||||
@@ -1,5 +1,363 @@
|
||||
# TODOS
|
||||
|
||||
## Issues #5+#6 follow-ups (pool starvation + process isolation; plan: ~/.claude/plans/system-instruction-you-are-working-witty-moore.md)
|
||||
|
||||
- [ ] **P1-companion — nested-checkout audit + dev-mode detection.** **What:**
|
||||
`transaction()` callers that invoke parent-engine methods (or module helpers
|
||||
taking `engine` not `tx`) take a SECOND read-pool slot while holding the tx
|
||||
slot — e.g. the `operations.ts` advisory-lock loop around `tx.addLink`. Under
|
||||
a saturated pool this is a client-side self-deadlock class. Audit call sites;
|
||||
add a dev-mode warning (e.g. a tx-depth counter consulted by `runUnsafe`).
|
||||
**Why:** the #6 incident's exact 240s-idle sessions were never reproduced
|
||||
under a debugger; this is the strongest remaining candidate — the shipped
|
||||
wave mitigates the starvation class but does not close this path. **Effort:**
|
||||
M. **Priority:** P1-companion.
|
||||
- [ ] **P2 — per-handler isolation policy.** **What:** a per-handler-name set
|
||||
(e.g. long-running LLM-bound handlers isolate, sub-second `lint`/`backlinks`
|
||||
stay inline) instead of the all-or-nothing `--job-isolation process`.
|
||||
**Why:** spawn cost (~0.3–1s) is noise for 644s subagent jobs, meaningful
|
||||
for sub-second handlers; one worker should be able to mix. **Context:**
|
||||
`worker.ts` executeJob's `isolated` gate is the seam. **Effort:** M.
|
||||
**Priority:** P2.
|
||||
- [ ] **P2 — per-child --max-rss caps.** **What:** RSS watchdog for isolation
|
||||
children (the worker-level watchdog covers the worker only in process mode;
|
||||
a startup note ships today). **Context:** child-job-runner.ts owns the child
|
||||
lifecycle; a poll of the child's RSS + group-kill on breach mirrors the
|
||||
worker watchdog. **Effort:** M. **Priority:** P2.
|
||||
- [ ] **P2 — jobs-side connection-budget clamp for isolated workers.** **What:**
|
||||
warn/clamp concurrency when `concurrency × (child pool + 1) + parent pools`
|
||||
exceeds a configured budget (GBRAIN_MAX_CONNECTIONS-style; precedent
|
||||
`sync-concurrency.ts:clampWorkersForConnectionBudget`). **Why:** isolation
|
||||
multiplies pooler CLIENT connections (~73 at concurrency 15); today the
|
||||
budget lives only in docs math. **Effort:** S. **Priority:** P2.
|
||||
- [ ] **P3 — --job-isolation pass-through for the autopilot's embedded
|
||||
supervisor.** **What:** `autopilot.ts` builds its own worker args; add the
|
||||
conditional flag there (jobs supervisor already passes through). **Effort:**
|
||||
S. **Priority:** P3.
|
||||
- [ ] **P3 — runLockRenewalTick adoption in the cycle drain.** **What:**
|
||||
`synthesize.ts` now uses the minimal `runDrainRenewalTick` (per-call signal +
|
||||
guard); adopting the full tick would add the audit channel + bounded
|
||||
reconnect. **Effort:** S. **Priority:** P3.
|
||||
- [ ] **P3 — streaming child progress.** **What:** isolation children report
|
||||
progress via their own token-fenced DB writes today (identical to inline);
|
||||
an IPC stream would only add parent-side visibility (e.g. lifecycle events
|
||||
in `jobs watch`). **Effort:** M. **Priority:** P3.
|
||||
- [ ] **P3 — connection-audit release events + plain-idle visibility.**
|
||||
**What:** `logConnectionEvent` never emits `release`, so the JSONL cannot
|
||||
answer "who holds a slot"; and `getIdleBlockers` filters
|
||||
`state='idle in transaction'` only — the #6 incident's plain-`idle` sessions
|
||||
were invisible to it. **Effort:** M. **Priority:** P3.
|
||||
- [ ] **P3 — doctor connection_routing check.** **What:** wire
|
||||
`ConnectionManager.describeMode()` + `healthCheck()` (both currently
|
||||
zero-caller outside tests) into a doctor check naming the routing mode,
|
||||
kill-switch state, and per-pool probe latency. Comments in four files
|
||||
already reference this check as if it existed. **Effort:** S.
|
||||
**Priority:** P3.
|
||||
- [ ] **P3 — isolation test-gap follow-ups (pre-landing review).** **What:**
|
||||
(a) spawned-CLI negative tests for `jobs run-child` bootstrap guards (PGLite
|
||||
→ exit 13; missing job-id/env → exit 13) and for `jobs work` with
|
||||
isolation on + an unresolvable child CLI (fail-fast exit 1) — both need a
|
||||
real engine bootstrap so they live in the e2e lane; (b) a behavioral (not
|
||||
structural) test driving `withRefreshingLock` with a hung injected
|
||||
`handle.refresh` (signal aborted at timeout, no overlapping ticks); (c) a
|
||||
force-evict-skip test for isolation mode (needs the 30s evict window made
|
||||
injectable); (d) operator-flow message tests (verdict-tailored FATAL text,
|
||||
single-pool startup banner). **Why:** the ship coverage audit scored the
|
||||
wave 82% — these are the surviving gaps. **Effort:** M. **Priority:** P3.
|
||||
- [ ] **P3 — raceWithAbortTimeout shared helper.** **What:** the
|
||||
"Promise.race a query vs a setTimeout that aborts an AbortController,
|
||||
clearTimeout in finally" pattern now exists at five sites (db-probe
|
||||
withDeadline, synthesize runDrainRenewalTick, lock-renewal-tick callAbort,
|
||||
db-lock tickAbort, supervisor probeAbort), each re-deriving the same
|
||||
invariants. Extract one helper and adopt it. **Effort:** S. **Priority:** P3.
|
||||
- [ ] **P3 — lazy handler resolution in run-child.** **What:** every isolation
|
||||
child runs full registerBuiltinHandlers (incl. plugin discovery) to resolve
|
||||
ONE handler; the job name is known from the row — a resolve-by-name path
|
||||
would skip discovery for builtins. Matters only if isolation is ever used
|
||||
for short jobs (documented as not the target). **Effort:** S. **Priority:** P3.
|
||||
- [ ] **P3 — full checkout instrumentation via a Sql proxy.** **What:** the
|
||||
CheckoutGauge covers raw/direct/reserved/tx seams only; tagged-template
|
||||
traffic (most engine load) is untracked. A proxy around the postgres.js Sql
|
||||
callable could count real checkouts — investigate cost/fragility before
|
||||
building. **Why:** would turn the probe's "tracked subset" caveat into full
|
||||
coverage. **Effort:** M. **Priority:** P3.
|
||||
|
||||
## Security-process follow-ups (filed with Wave −1 of the fix-wave campaign, 2026-08-14)
|
||||
|
||||
- [ ] **P2 — Vulnerability disclosure policy.** **What:** a written disclosure
|
||||
process: advisory ownership, severity ladder, reporter acknowledgment SLA,
|
||||
embargo windows, private patch review, supported-version/backport policy,
|
||||
release timing, post-release rotation guidance. **Why:** private vulnerability
|
||||
reporting is now enabled (#579) and a reporter has a channel, but a channel
|
||||
without a process leaves triage decisions ad-hoc; a public PR diff can still
|
||||
broadcast attack surface mid-embargo. **Context:** filed from the fix-wave
|
||||
campaign's Codex review (CX-11); the campaign deliberately shipped only the
|
||||
toggle + reporter acknowledgment. Start from the responsible-disclosure rules
|
||||
already in CLAUDE.md and docs/RELEASING.md. **Effort:** M. **Priority:** P2.
|
||||
## Code-smell fix-wave deferrals (filed at W0; plan: ~/.claude/plans/system-instruction-you-are-working-encapsulated-eclipse.md)
|
||||
|
||||
Each was individually decided as a deferral in the CEO/eng reviews of the
|
||||
fix-wave plan; the wave series (W0.5–W9, 3.4, 3.6) tracks its own scope there.
|
||||
|
||||
- [ ] **Full engine staged merge** (~10 domains onto shared query modules +
|
||||
Dialect record). **Priority: P2.** Gated on the W9 two-slice pilot criteria
|
||||
(structure+params+results parity on chronicle AND the searchKeyword/CJK
|
||||
hard seam; ≥40% domain LOC cut; Dialect ≤~6 fields; query-builder extension
|
||||
≤~150 lines). The terminal fix for the engine-divergence/JSONB class —
|
||||
blast radius is the production hot path, hence pilot-gated. Blocked by: W9.
|
||||
- [ ] **gateway.ts file split** behind a re-export facade (~121 import sites
|
||||
unmoved). **Priority: P3.** After W8's behavior changes so the split is
|
||||
pure motion; needs the CLAUDE.md engine-dynamic-import exemption-path
|
||||
chasers + check-engine-dynamic-import.sh + build:llms.
|
||||
- [ ] **BrainEngine 149-method interface → domain repos** (65 methods have
|
||||
0-1 callers; 3 already deleted in W3). **Priority: P3.** Shape informed by
|
||||
the W9 pilot's query-module seam.
|
||||
- [ ] **Legacy Anthropic-SDK subagent loop deletion.** **Priority: P2.** One
|
||||
release after W8 flips `agent.use_gateway_loop` default ON (flag stays as
|
||||
the revert path for that release).
|
||||
- [ ] **Deeper test-suite speedup** beyond the W0 snapshot default-on (which
|
||||
already cut the full parallel suite ~4,900s → ~490s). **Priority: P3.**
|
||||
Revisit with post-W0 timing data; diminishing returns until measured.
|
||||
- [ ] **PGLite schema build-time derivation** from SCHEMA_SQL via a named
|
||||
transform list. **Priority: P3.** Only if W3's schema drift TEST proves
|
||||
annoying in practice — the test alone kills the drift bug class (Codex
|
||||
D4.8/D5.23: fresh-schema equivalence ≠ upgrade correctness; old-shape
|
||||
bootstrap fixtures + replay coverage stay regardless).
|
||||
## Jobs fix-wave follow-ups (filed v0.45.15.0 — upstream issues #2/#3/#4)
|
||||
|
||||
- [ ] **P2 — `jobs submit --max-pending` public flag.** maxPending stays an
|
||||
internal submit option this wave (Codex C4): its semantics exclude
|
||||
delayed/paused/waiting-children rows, and identity is (name, queue, source)
|
||||
so distinct payloads collapse. Decide the public contract (include delayed?
|
||||
explicit scope key?) after the primitive soaks in autopilot, then mirror
|
||||
parseMaxWaitingFlag (clamp [1,100]) + help + flag-registry regen + optional
|
||||
submit_job MCP param. Where: src/commands/jobs.ts, src/core/operations.ts.
|
||||
- [ ] **P2 — maxPending at the other single-flight dispatch sites.** The
|
||||
freshness sync submit (src/commands/autopilot.ts freshness loop) and the
|
||||
targeted remediation steps (autopilot.ts targeted-submit loop) still use
|
||||
maxWaiting: 1; widening to maxPending changes behavior of those lanes
|
||||
(suppression while a long run is active) and needs its own review. Where:
|
||||
src/commands/autopilot.ts.
|
||||
- [ ] **P2 — Help-stub sweep for the other CLI_ONLY commands.** The `jobs`
|
||||
defect class exists elsewhere: `gbrain search modes --help` connects an
|
||||
engine before help routing, and the search subcommands have no help guards
|
||||
(jobs/bootstrap/skillpack now carry the guard pattern to copy). Audit every
|
||||
CLI_ONLY member missing from CLI_ONLY_SELF_HELP; the top-level help promises
|
||||
per-command help for all of them. Where: src/cli.ts, src/commands/search.ts.
|
||||
- [ ] **P3 — jobs stats: fuller backpressure/audit surfacing.** The 24h
|
||||
Backpressure line + suppressed-by hint shipped; per-decision breakdowns,
|
||||
longer windows, and doctor integration remain (the audit file header's B4
|
||||
follow-up). Where: src/commands/jobs.ts, src/core/minions/backpressure-audit.ts.
|
||||
- [ ] **P3 — jobs watch: timeout/deadline column.** `jobs get` shows the
|
||||
effective budget; the live dashboard doesn't. Where: src/commands/jobs-watch.ts.
|
||||
- [ ] **P3 — jobs help + operator docs: handler catalog and dispatch-event
|
||||
schema.** `gbrain jobs --help`'s HANDLER TYPES section lists 8 of the ~40
|
||||
registered handlers, and the autopilot dispatch JSON events (`dispatched`,
|
||||
`dispatch_coalesced`, `fanout_summary` with its `coalesced` array) have no
|
||||
schema documentation outside the CHANGELOG. Where: src/commands/jobs.ts
|
||||
(JOBS_HELP), docs/guides/queue-operations-runbook.md.
|
||||
|
||||
## 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 (15–30s) 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 15–30s 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 — 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 +
|
||||
@@ -77,7 +435,7 @@ Deferred from the BrainBench wave (eng-reviewed; plan + GSTACK REVIEW REPORT at
|
||||
|
||||
- [ ] **`--live` agent-in-the-loop know-to-ask.** Replay fixtures with a real model deciding whether to issue retrieval calls; grade the agent, not just the deterministic reflex. Pre-registered in `docs/eval/BRAINBENCH.md` (the v1 metric grades the injection decision, which IS the shipped mechanism). Needs: seeded N-repeat methodology for model stochasticity + budget rails. Priority: P2.
|
||||
- [ ] **Intrusion-budget gating calibration.** `avg_injected_tokens` is reported, non-gating (decision 18) — a wrong threshold is worse than none. After a few weeks of scoreboard data across PRs, pick calibrated per-seam thresholds and promote it to a gated metric. Priority: P2.
|
||||
- [ ] **Flip contract adapters to production — claude-code half now unblocked.** `adapters/claude-code.ts` exports the UserPromptSubmit hook wire types; the real hook (`gbrain hook user-prompt`, shipped with the bootstrap lane and extended with cross-turn dedupe + the channel feedback loop in the cathedral-3 convergence) swaps the in-process transport for an exec of the hook script and flips `seam: 'contract'` → `'production'` with continuous bench numbers. Note the production hook also exercises transcript-based dedupe, which the memoryless contract row deliberately doesn't. Same for codex fragments when that integration lands. Priority: P1 (the claude-code integration has landed; this is now standalone-actionable).
|
||||
- [ ] **Flip contract adapters to production — claude-code half now unblocked.** `adapters/claude-code.ts` exports the UserPromptSubmit hook wire types; the real hook (`gbrain hook user-prompt`, shipped with the bootstrap lane and extended with cross-turn dedupe + the channel feedback loop in the cathedral-3 convergence) swaps the in-process transport for an exec of the hook script and flips `seam: 'contract'` → `'production'` with continuous bench numbers. Note the production hook also exercises transcript-based dedupe, which the memoryless contract row deliberately doesn't. For the codex half: the cathedral-4 transcripts lane shipped a verified codex rollout PARSER (`src/core/transcripts/codex.ts`, structural turn selection pinned against a live sample) — a codex contract adapter can now consume it instead of waiting for a hook integration. Priority: P1 (the claude-code integration has landed; codex parsing has landed; this is now standalone-actionable).
|
||||
- [ ] **Cathedral 1 conformance-kit fixture import.** The memory-verbs conformance scenarios convert to BrainBench fixtures via the published `evals/brainbench/schema/fixture.schema.json` once `garrytan/cathedral-1` merges ("conformance tests double as BrainBench seed fixtures", decision log 2026-06-12). Free corpus growth from already-reviewed scenarios. Blocked by: cathedral-1 on master. Priority: P2.
|
||||
- [ ] **Live-embeddings fidelity mode (`--embeddings`).** Hermetic CI grades the keyword/alias arms only (disclosed); an opt-in mode seeding real embeddings would grade write-back/continuity retrieval through the vector path. Same budget rails as `--llm`. Priority: P3.
|
||||
- [ ] **Community fixture intake + competitor adapters.** The TD1 remainder after the generated corpus absorbed in-PR growth: an `external-authors/`-style intake path for contributed fixtures (validator + privacy guard already gate them) and adapters for non-gbrain memory systems against the published schemas, enabling true head-to-head rows in the gbrain-evals scorecard. Priority: P3.
|
||||
@@ -88,7 +446,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
|
||||
@@ -481,6 +843,16 @@ job) and sync. See CLAUDE.md "Pace Mode".
|
||||
supervisor-detection downgrade. Today these inherit config/env pacing only when
|
||||
they call `runEmbedCore`.
|
||||
- [ ] **P1-companion — Supervisor concurrency 3→2 + job-kind slot fairness (E7).**
|
||||
**v0.45.15.0 annotation (jobs fix wave):** make the whole wedge-detector FAMILY
|
||||
suppression-aware while here — the supervisor watchdog (supervisor.ts wedge
|
||||
predicate) and doctor's `wedged_queue` check both require waiting > 0, and
|
||||
`maxPending` single-flight keeps waiting at 0 while a job is in flight.
|
||||
Mitigations already shipped: maxPending counts only LIVE-LOCK actives (a
|
||||
dead/blocked worker's expired-lock row never suppresses, so fresh waiting rows
|
||||
re-feed the detectors) and `jobs stats` prints a Backpressure line + a
|
||||
suppressed-by hint. Remaining: teach watchdog/doctor to treat
|
||||
recent-coalesces + stale live active as wedge signal; also note the worker
|
||||
in-flight stall-check hole (worker.ts stall check skips when inFlight > 0).
|
||||
The daemon-side root cause the external wrapper's probe was blind to:
|
||||
`embed-backfill`/`autopilot-cycle` jobs can occupy all supervisor slots
|
||||
(`:215` below). Pacing makes backfills safe; this fixes the residual death rate.
|
||||
@@ -587,7 +959,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
|
||||
@@ -1031,11 +1406,14 @@ but were deliberately scoped OUT — neither is a #1784 regression.
|
||||
deserves its own deliberate change. Fix: mirror the extracted
|
||||
`buildCostRefusal({json, ...})` helper (`reindex-code.ts`). The guardrail
|
||||
(exit 2, no spend) stays; only the FORMAT splits on `--json`.
|
||||
- [ ] **P3 — `gbrain jobs --help` has no subcommand list.** jobs.ts dispatches
|
||||
- [x] **P3 — `gbrain jobs --help` has no subcommand list.** jobs.ts dispatches
|
||||
on a bare subcommand string with no HELP const, so `watch` (and every other
|
||||
jobs subcommand) is undocumented in `--help`. The new `watch` `--json` /
|
||||
`--follow` flags are documented only in the file JSDoc. Add a HELP table to the
|
||||
`jobs` command listing every subcommand + its flags.
|
||||
**Completed:** v0.45.15.0 (2026-08-14) — JOBS_HELP + JOBS_SUBCOMMAND_HELP with a
|
||||
guard above the thin-client refusal; `jobs`/`jobs work` etc. `--help` print real
|
||||
usage engine-free and can never start a daemon.
|
||||
|
||||
## v0.42.12.0 self-upgrade follow-ups (v0.43+)
|
||||
|
||||
@@ -1207,7 +1585,11 @@ and tested; these are documented tradeoffs and stronger-but-bigger versions.
|
||||
Deferred from the v0.41.38.0 wave (code-callers/callees pin + dream-on-postgres).
|
||||
Documented tradeoffs, not blockers — the shipped bug fixes are complete and tested.
|
||||
|
||||
- [ ] **P1 — Per-source autopilot fan-out passes the global repoPath.**
|
||||
- [x] **P1 — Per-source autopilot fan-out passes the global repoPath.**
|
||||
**Completed (verified already fixed):** v0.45.15.0 audit (2026-08-14) — the
|
||||
handler binds FS phases to the source's `local_path` and never falls through
|
||||
to the global repoPath (`effectiveBrainDir = sourceId ? sourceLocalPath :
|
||||
repoPath` in src/commands/jobs.ts, with per-source null → skip FS phases).
|
||||
`src/commands/autopilot-fanout.ts:~206` submits every per-source `autopilot-cycle`
|
||||
job with `repoPath: opts.repoPath` (the global checkout), not `src.local_path`.
|
||||
With v0.41.38.0's `cycleSourceId = opts.sourceId ?? resolveSourceForDir(...)`,
|
||||
@@ -1573,18 +1955,14 @@ single canonical `src/core/model-pricing.ts` with `canonicalLookup`.
|
||||
operator pipes directly into `crontab -e` instead of copy-paste-massage.
|
||||
~80 LOC. Mirrors `gbrain sync --break-lock` argv shape.
|
||||
|
||||
- **TODO-OPS-2 (P2)**: Lock-loss detection — extend `DbLockHandle.refresh()`
|
||||
to throw `LockLostError` on 0 rows affected. Codex caught during the
|
||||
v0.41.19.0 plan review: `refresh()` runs `UPDATE ... WHERE holder_pid = pid`
|
||||
with no rows-affected check (`db-lock.ts:108-114`, `:151-156`). If the
|
||||
TTL expired and another worker took over, the original keeps writing
|
||||
silently. v0.41.19.0 ships TTL=5min + active in-phase refresh via
|
||||
`buildYieldDuringPhase` which makes the race window much narrower, but
|
||||
an `await chat()` call that exceeds the 5min wallclock window can still
|
||||
hit it. Fix: `RETURNING id` on the UPDATE + check `rows.length === 0` →
|
||||
throw tagged `LockLostError`. Phases catch + abort cleanly (write partial
|
||||
progress, return `status: 'fail'` with reason `'lock_lost'`). Behavioral
|
||||
contract change with phase-abort fallout; needs its own design pass.
|
||||
- [x] **TODO-OPS-2 (P2)**: Lock-loss detection — CLOSED by the W0 fix-wave
|
||||
(code-smell series). `refresh()` now runs a FENCED update (id + holder_pid +
|
||||
epoch-rendered `acquired_at`) with `RETURNING id`, returns `false` on 0
|
||||
rows, and runCycle's steal controller aborts the run at the next boundary
|
||||
with a structured `reason: 'lock_stolen'` partial report (LockStolenError;
|
||||
raced awaits cover the 5 long phases). The supervisor exits LOCK_LOST
|
||||
immediately on a fenced miss. Pinned by `test/db-lock-fencing.test.ts` +
|
||||
`test/cycle-lock-steal.serial.test.ts`.
|
||||
|
||||
## v0.41.20.0 status + doctor-categories wave follow-ups (v0.42+)
|
||||
|
||||
@@ -3829,28 +4207,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 +4319,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.
|
||||
|
||||
@@ -3892,7 +4341,7 @@ After the sweep, both should be fixable and renameable back to plain `*.test.ts`
|
||||
### PTY-mode transcript capture
|
||||
**Priority:** P3
|
||||
|
||||
**What:** `transcript-capture.ts` currently uses plain `child_process.spawn` pipes. Some agents only emit ANSI colors / progress UI on a TTY. v1.1 adds a PTY mode (likely via `node-pty`) so live-mode transcripts capture the full agent UX.
|
||||
**What:** `transcript-capture.ts` currently uses plain `child_process.spawn` pipes. Some agents only emit ANSI colors / progress UI on a TTY. v1.1 adds a PTY mode so live-mode transcripts capture the full agent UX. Do NOT add node-pty for this: Bun's built-in `terminal:` spawn option (Bun 1.3.10+, already pinned in engines) is the dependency-free path, proven by `test/helpers/tty-harness.ts` — reuse `launchTty` or its spawn shape.
|
||||
|
||||
**Why:** Faithful transcripts make the friction → reasoning link more useful. v1 accepts that some agent UI is lost.
|
||||
|
||||
@@ -3900,6 +4349,28 @@ After the sweep, both should be fixable and renameable back to plain `*.test.ts`
|
||||
|
||||
---
|
||||
|
||||
### Non-tier-1 e2e files run in no required CI lane
|
||||
**Priority:** P2
|
||||
|
||||
**What:** Unit shards exclude `test/e2e/*` (`scripts/test-shard.sh`), and `.github/workflows/e2e.yml` runs only explicitly named files (a handful across its jobs — e.g. `test/e2e/mechanical.test.ts`, `test/e2e/mcp.test.ts`, the jsonb-parity pair); there is no glob. Every other `test/e2e/*.test.ts` — including PGLite-only files that need no `DATABASE_URL`, like `init-fresh-pglite.test.ts` — executes only when someone runs `bun run test:e2e` by hand. Decide per file: wire into a required workflow, re-home PGLite-only files to the serial lane (the pattern `test/init-picker-pty.serial.test.ts` uses), or explicitly document them as manual-only.
|
||||
|
||||
**Why:** Tests that never run in required CI are silent coverage loss — they rot without failing. Surfaced by the TTY-harness cleanup review when the new PTY picker test almost landed in the same dead lane.
|
||||
|
||||
**Effort:** S-M (CC ~30-60m for the audit + re-homing; workflow wiring adds CI-minutes cost per file).
|
||||
|
||||
---
|
||||
|
||||
### Ctrl-D during `gbrain init` stalls 60s at the next prompt (readLineSafe does not latch EOF)
|
||||
**Priority:** P2
|
||||
|
||||
**What:** Pressing Ctrl-D at the interactive provider picker is detected immediately (keyless fallback in ~200ms), but Bun's stdin never yields another line after EOF while `isTTY` stays true — so the SUBSEQUENT search-mode picker sits its full 60s `readLineSafe` fallback before init completes (probed under a real PTY: keyless notice at 0.2s, mode prompt rendered at 1.2s, exit at 61.1s). Fix: `readLineSafe` (src/commands/init.ts) should latch EOF — once stdin has ended, later calls return their default immediately instead of waiting out the timer. Regression test: extend the EOF case in `test/init-picker-pty.serial.test.ts` to run init to completion and assert exit well under the fallback window (the case currently closes early on purpose to keep the 60s stall out of required CI — see the comment there).
|
||||
|
||||
**Why:** A user who hits Ctrl-D at the first prompt stares at a frozen screen for a full minute before init finishes. Cross-model adversarial review finding (Codex), confirmed by a real-PTY probe.
|
||||
|
||||
**Effort:** S (CC ~20m: EOF latch + regression-test extension).
|
||||
|
||||
---
|
||||
|
||||
### Read-side host-isolation (`$GBRAIN_HOST_HOME`)
|
||||
**Priority:** P3
|
||||
|
||||
@@ -5124,6 +5595,91 @@ 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
|
||||
@@ -5370,10 +5926,14 @@ respective shapes. Small, mechanical; pinned by `test/init-embed-check.test.ts`
|
||||
- [ ] **P2 — `gbrain ingest feed`: native feed adapter.** blog-ingest ships the
|
||||
agent-procedure layer; the durable path is a deterministic RSS/Atom adapter
|
||||
(discovery, pagination, canonical-URL dedup, 429 backoff) behind one command.
|
||||
- [ ] **P2 — Native AI-chat export importer.** conversation-archive converts
|
||||
ChatGPT/Claude/Perplexity exports via agent procedure; a native importer
|
||||
(export JSON → conversations/ pages) makes it deterministic. Pairs with the
|
||||
existing conversation-parser surface.
|
||||
- [x] **P2 — Native AI-chat export importer.** **Completed:** v0.46.0.0 (2026-08-14).
|
||||
`gbrain transcripts ingest` imports extracted ChatGPT and Claude.ai
|
||||
`conversations.json` exports natively (adapters at
|
||||
`src/core/transcripts/{chatgpt-export,claude-export}.ts`, rendering on the
|
||||
conversation-parser surface). Perplexity has no adapter yet — a candidate
|
||||
leaf module on the same `TranscriptAdapter` seam (the pattern the
|
||||
cathedral-4 "More harness adapters" follow-up below documents); the
|
||||
conversation-archive skill keeps the manual procedure for it meanwhile.
|
||||
- [ ] **P2 — Entity-guard as a native op.** phonetic-name-guard's own changelog
|
||||
proves prose-only failed: ASR-variant entity collisions need a native check
|
||||
(registry + alias table consulted at put/import time). The wave shipped the
|
||||
@@ -5432,3 +5992,253 @@ respective shapes. Small, mechanical; pinned by `test/init-embed-check.test.ts`
|
||||
subsystem that deserves its own eng + CEO review, not a rider. The currency work
|
||||
(`skillpack status`/`sync`, doctor `skill_currency`) already keeps the brain's skill
|
||||
set current on upgrade; this item is purely about semantic retrieval of skills.
|
||||
|
||||
## opencode wave follow-ups (filed at build time)
|
||||
|
||||
- [ ] **P2 — Watch the first opencode-door + canary dispatches.** The job is
|
||||
day-one full posture (nightly + labels; keyless SMOKE + paid anthropic leg
|
||||
on the existing secret) — after the wave merges, confirm the first nightly
|
||||
run goes green end-to-end and the canary leg's latest-version result, then
|
||||
update OPENCODE-CLI-PIN.md §Pending auth with anything the authed CI run
|
||||
observes (exact `opencode models` output, per-turn cost note). Effort: S.
|
||||
- [ ] **P3 — Wire opencode's plugin/event system** (the ambient-recall lane).
|
||||
opencode ships a JS plugin system with lifecycle events; `OPENCODE_HAS_HOOKS
|
||||
= false` in host-specs.ts marks the gap. Needs its own observation pass
|
||||
(plugin API shapes, event timing, context-injection surface) before design —
|
||||
would upgrade opencode from pull-protocol to per-turn push, above codex.
|
||||
Effort: M/L.
|
||||
- [ ] **P3 — BrainBench opencode adapter.** `src/eval/brainbench/adapters/` +
|
||||
`ALL_HARNESSES` entry — build together with the already-filed hermes + grok
|
||||
adapters (three pending; one eval wave). Effort: M.
|
||||
- [ ] **P3 — connect `--agent opencode --oauth`.** opencode's `mcp auth` is an
|
||||
authorization-code OAuth flow (not client-credentials) — a connect lane for
|
||||
it needs the interactive-grant plumbing the current `--oauth`
|
||||
(perplexity/generic client-credentials) path does not model. Effort: M.
|
||||
- [ ] **P3 — Re-observe the OPENCODE_CONFIG* env trio on version bumps.**
|
||||
Observed INERT in 1.18.18 (docs-contradiction pinned in OPENCODE-CLI-PIN.md
|
||||
§Path seams); host-specs resolves via XDG only. If a future release
|
||||
activates them, `opencodeConfigDir()` and the hermetic child-env deletes
|
||||
must move together. The pin doc's re-observation checklist carries the
|
||||
probe. Effort: S.
|
||||
- [ ] **P3 — opencode-install PTY promotion.** Same criterion as grok-install:
|
||||
2 consecutive stable dx-scenario runs ≥1 month apart with unchanged
|
||||
boot/first-run copy → promote to a PTY assertion test. opencode's keyless
|
||||
free tier means the scenario should COMPLETE the bootstrap, making it a
|
||||
stronger promotion candidate than grok's sign-in-wall early-stop. Effort: M.
|
||||
|
||||
## Transcripts-import follow-ups (filed from cathedral-4, `gbrain transcripts ingest`)
|
||||
|
||||
Scoped OUT of the cathedral-4 PR by the CEO review's cherry-pick ceremony and the
|
||||
eng review — each carries a named design, none is a bug. Context: the import lane
|
||||
(adapters at `src/core/transcripts/`, session-atomic pipeline, embed-OFF default)
|
||||
covers DEAD logs; go-forward capture beyond Claude Code is deliberately absent.
|
||||
|
||||
- [ ] **OpenClaw go-forward capture.** Blocked upstream: the OpenClaw PluginApi exposes only `registerContextEngine` — no end-of-turn/agent-end capability. When the host grows one, the plugin (`src/openclaw-context-engine.ts`) subscribes and emits the session into the corpus lane (`~/.gbrain/transcripts/corpus` sidecar protocol) the way `gbrain hook session-end` does for Claude Code; the openclaw session PARSER already ships. Consent must ride a capture line like the bootstrap harness `--no-capture` model. Priority: P2.
|
||||
- [ ] **Codex go-forward capture (notify sweeper).** `docs/designs/AGENT_BOOTSTRAP_PLAN.md` FF2 names the design (notify sweeper over `~/.codex/sessions`); the rollout parser now ships in `src/core/transcripts/codex.ts`, so the sweeper is pure wiring: on codex notify, run `gbrain transcripts ingest <rollout> --quiet`. Needs the same consent posture as capture. Priority: P2.
|
||||
- [ ] **Scheduled re-import cycle phase.** `transcripts ingest --since last --all` as an opt-in cycle phase so dead-log import self-refreshes. REQUIRES its own consent-line design first: reading harness dirs on a schedule is capture-adjacent (the "Autonomous transcript watchers" decision above rules the spirit); the clean-scan watermark + status gap table already make manual re-runs cheap. Priority: P3.
|
||||
- [ ] **PII auto-detection redaction pass for imports.** The native lane redacts secrets (secret-scan) + user patterns (`harvest-private-patterns.txt`, emails included) and counts imperatives; broad PII detection (names, phones, addresses) is its own subsystem — the conversation-archive skill keeps the human scrub step for sensitive corpora meanwhile. Priority: P2.
|
||||
- [ ] **More harness adapters: Cursor / Gemini CLI / Copilot CLI.** Leaf modules on the `TranscriptAdapter` seam (~1h each with an agent): dated SPEC_TARGET + scrubbed fixture + drift alarm, per the shipped six. Formats unverified locally — verify a real sample first (the hermes gate pattern). Priority: P3.
|
||||
- [ ] **ChatGPT/Claude.ai export zip unwrapping.** v1 requires the EXTRACTED `conversations.json` ("unzip first" is documented + error-hinted). Add zip handling without a heavy dependency (Bun has no built-in zip; evaluate a minimal vendored inflate or shelling to `unzip` with confinement). Priority: P3.
|
||||
- [ ] **BrainBench raw-format fixture schema (sibling repo).** The in-repo pin (`test/e2e/transcripts-writeback-fidelity.test.ts`) grades raw files through the adapters with the gold extractor, but the BrainBench corpus schema (gbrain-evals) still rejects unknown keys and its corpus hash doesn't cover raw sidecars. Needs: versioned raw-fixture sidecar type + loader + hash coverage + baseline re-cut in gbrain-evals, then a `write_back_fidelity_raw` suite row here. Priority: P2.
|
||||
- [ ] **Hermes SPEC_TARGET verification against a populated store.** The schema came from the installed hermes-agent v0.20.0 source (`SCHEMA_SQL`), but no populated `state.db` existed on the dev machine — the fixture is synthetic-by-declaration. Verify against a real store after some Hermes sessions accrue, then flip `status: 'provisional'` → `'verified'` and pin the `active`/`compacted` semantics the adapter currently ignores. Priority: P3.
|
||||
## Grok Build wave follow-ups (filed at build time)
|
||||
|
||||
- [ ] **P1 — Enable the grok-door paid lane once XAI_API_KEY exists.** Admin
|
||||
creates the `XAI_API_KEY` repo/environment secret (console.x.ai; prefer a
|
||||
protected GitHub Environment scoped to door jobs), then one commit re-adds
|
||||
to `grok-door` in heavy-tests.yml: the `schedule` leg, the `heavy-tests`
|
||||
label leg, default-on dispatch, and a latest-version CANARY matrix leg
|
||||
(`continue-on-error`, schedule-scoped, own timeout) so the pinned lane stays
|
||||
deterministic while the canary tracks what users run. Same session: run the
|
||||
pending-auth Phase-0 observations (paid one-shot smoke, authed model list +
|
||||
measured per-turn cost pins, credential-file inventory after login → door
|
||||
evidence exclusions + TTY secretPaths, authed first-run TUI copy) into
|
||||
`docs/mcp/GROK-CLI-PIN.md`, and pin `parseGrokJson` + the separate
|
||||
non-retried JSON toolCall door test (one extra paid turn) once the
|
||||
streaming-json event shape is observed. Effort: S (CC ~30min + admin).
|
||||
- [ ] **P2 — `gbrain connect --agent grok`.** One-command install UX:
|
||||
`AgentId`/`AGENT_SPECS`/`AGENT_IDS` in `src/commands/connect.ts`, a
|
||||
`buildGrokMcpAddArgv` in `src/core/mcp-registration.ts` (shape already
|
||||
pinned in GROK-CLI-PIN.md), connect tests ("all four agents" pin moves to
|
||||
five), KEY_FILES entry. Deferred from the grok wave to avoid a second
|
||||
observation pass; the pin doc now exists, so this is mechanical. Effort: S.
|
||||
- [ ] **P2 — HERMES.md surface refresh.** The hermes register command predates
|
||||
the truthful-surface wave and wires the full 100+-op catalog;
|
||||
CLAUDE_CODE.md + GROK.md now recommend `--surface verbs`. Update the
|
||||
register one-liner + Direct config block (+ INSTALL_FOR_AGENTS hermes
|
||||
block) and re-verify against the pinned hermes. Effort: S.
|
||||
- [x] **P2 — Backport the GITHUB_ENV/GITHUB_PATH/GITHUB_OUTPUT/GITHUB_STATE
|
||||
deletion from `grokChildEnv` to `hermesChildEnv`** (and consider narrowing
|
||||
the `GITHUB_` ALLOW_PREFIX to the read-only metadata names) — the prefix
|
||||
rule forwards writable CI step-metadata files to untrusted agent children.
|
||||
Unit truth-table exists for the grok side to clone. Effort: S.
|
||||
DONE (opencode-support wave): `hermesChildEnv` now rides `makeAgentChildEnv`,
|
||||
which scrubs the GITHUB_* step-metadata files for every door agent; truth-table
|
||||
extended in `test/helpers/agent-harness.unit.test.ts`.
|
||||
- [ ] **P3 — Grok bootstrap-harness target.** `gbrain bootstrap` personal-agent
|
||||
support for Grok Build: `HarnessSelector` + `parseHarnessArgs`, a dated
|
||||
`TARGETS` spec in `host-specs.ts`, a `wireGrok` branch + TOML writer (grok
|
||||
config schema pinned; `codex-toml.ts` is the precedent), receipt/rollback/
|
||||
status handling, and the INSTALL_FOR_AGENTS honest-classification flip.
|
||||
Docs currently state "bootstrap does not support Grok yet". Effort: M.
|
||||
- [x] **P3 — Door-adapter extraction (test-side) + door cadence policy.**
|
||||
Trigger FIRED at the 4th door agent (opencode, the opencode-support wave):
|
||||
`makeBinaryResolver`/`makeAgentChildEnv`/`runOneShotSpawn` extracted in
|
||||
`test/helpers/agent-harness.ts`, grok+hermes ported (hermes gained the
|
||||
GITHUB_* scrub + bounded drain), opencode landed as first consumer; the
|
||||
cadence policy is adopted in `docs/TESTING.md` (nightly for the newest
|
||||
agent, label-only after 2 stable monthly cycles).
|
||||
- [ ] **P3 — Door CI-tail composite action.** Trigger: the FIRST GREEN
|
||||
grok-door AND opencode-door dispatches (workflow yaml cannot be proven
|
||||
locally, and refactoring never-run jobs compounds risk — grok-door has
|
||||
never dispatched: its XAI_API_KEY secret does not exist yet). Hoist the
|
||||
shared workflow tail (evidence prep / scrub triple / upload / pass-count +
|
||||
paid sentinels / version re-check / cred cleanup) from
|
||||
hermes-door/grok-door/opencode-door into a composite action; port
|
||||
opencode-door as first consumer (it is the freshest copy). Until then the
|
||||
three doors' scrub blocks carry cross-reference comments. Effort: M.
|
||||
- [ ] **P3 — Promote grok-install to a PTY assertion test.** Criterion: 2
|
||||
consecutive stable runs ≥1 month apart of the dx scenario (pre-ship ritual
|
||||
on grok-touching waves) with unchanged boot/sign-in copy. Would be the
|
||||
repo's first gbrain-driving PTY assertion test — keep it an instrument
|
||||
until the copy proves stable. Effort: M.
|
||||
- [ ] **P3 — Nightly cross-agent friction-diff artifact.** After door runs,
|
||||
`gbrain friction diff --base <hermes-run> --compare <grok-run>` rendered
|
||||
into a CI artifact so guide-following friction regressions surface without
|
||||
a dev-box session. Effort: S/M.
|
||||
- [ ] **P3 — `xai:` provider block in model-pricing.ts.** Grok models
|
||||
(grok-4.6/4.5 observed) for cost views once xAI pricing is sourced;
|
||||
separate concern from the harness wave (CANONICAL_PRICING discipline).
|
||||
Effort: S.
|
||||
- [ ] **P3 — BrainBench grok adapter.** `src/eval/brainbench/adapters/` +
|
||||
`ALL_HARNESSES` entry — same seam as the already-filed hermes adapter
|
||||
(TODOS "BrainBench hermes adapter"); build both together. Effort: M.
|
||||
- [ ] **P3 — Client-registry unification (Approach C).** The repo carries 7
|
||||
hardcoded client lists (connect AGENT_SPECS, bootstrap Harness,
|
||||
HarnessSelector, host-specs TARGETS, claw-test registry, brainbench
|
||||
ALL_HARNESSES, volunteer HARNESS_CHANNELS); grok proved the claw-test
|
||||
registry shape generalizes. Unify into one data-driven table AFTER the
|
||||
door-adapter extraction lands (earn it — don't freeze hermes-isms in).
|
||||
Effort: L.
|
||||
- [x] **P3 — PIN-doc privacy guard.** DONE (opencode-support wave):
|
||||
`scripts/check-pin-doc-privacy.sh` (in `bun run verify` + guards-manifest,
|
||||
fixture-tested) asserts every `docs/mcp/*-CLI-PIN.md` uses placeholder paths
|
||||
and carries no key-shaped material or non-example emails.
|
||||
- [x] **P3 — opencode-door npm view-vs-install TOCTOU.** DONE (adversarial-review
|
||||
fix wave): the door job's install step is now pack-verify-install — `npm pack
|
||||
<pkg>@<ver> --json` downloads the artifact and reports the integrity of the
|
||||
BYTES written; both the wrapper and the platform payload are asserted against
|
||||
their pins before `npm install -g ./opencode-ai-*.tgz` installs from the
|
||||
verified local tarball (no fresh registry resolve of the name; the payload's
|
||||
install-time fetch is npm-validated against the same byte-confirmed packument).
|
||||
Verified locally on darwin-arm64 (wrapper integrity == pin; `--ignore-scripts`
|
||||
breaks opencode's postinstall binary placement, so it is deliberately absent).
|
||||
- [x] **P3 — `opencode mcp list` probe spawns project-config servers.** DONE
|
||||
(adversarial-review fix wave): the user-scope probe spawns from a fresh EMPTY
|
||||
mkdtemp cwd (no project config can load), project scope SKIPS the live probe
|
||||
entirely with a printed note (parse-back is authoritative), and the probe now
|
||||
holds the real process handle so the 20s timeout actually kills the child
|
||||
(SIGTERM → SIGKILL) instead of abandoning it.
|
||||
- [ ] **P3 — dedupe the opencode read→parse→classify dance.** The
|
||||
read-config → parseOpencodeConfig → opencodeEntryKind sequence is spelled
|
||||
three times (bootstrap.ts runHooks pre-check, harness.ts apply expectUrl
|
||||
fallback, harness.ts remove ownership check); extract a
|
||||
`classifyOpencodeEntryAt(path, name, expect)` helper and drop the
|
||||
double-printed other-source warning (the caller AND the writer note it).
|
||||
Effort: S.
|
||||
|
||||
## opencode adversarial-review fix-wave follow-ups (filed at fix time)
|
||||
|
||||
- [ ] **P2 — per-harness MCP-scope consent key.** An interview MCP_SCOPE answer
|
||||
recorded for Claude Code (where 'project' is the privacy-SAFE default)
|
||||
currently authorizes opencode's INVERTED-risk scopes without fresh
|
||||
confirmation ('project' on opencode = committed file that auto-spawns on
|
||||
every collaborator machine, no trust gate), and an ABSENT answer defaults
|
||||
opencode to user-global exposure (any repo on the machine reaches the
|
||||
brain). Design a harness-specific consent confirm — either per-harness
|
||||
answer keys (MCP_SCOPE_OPENCODE) or a one-time "your recorded scope means
|
||||
something riskier here — confirm" gate on the opencode lane. Relates to the
|
||||
agent-bootstrap A8 consent-semantics TODO. Effort: M.
|
||||
- [ ] **P3 — opencodeEntryKind remote ownership: normalize the url compare.**
|
||||
Ownership uses exact string equality on the entry url vs the receipt/expect
|
||||
url — trailing-slash and host-case variants misclassify in BOTH directions
|
||||
(ours read as foreign → orphaned entry; a variant-url foreign endpoint
|
||||
never matches, fine, but the asymmetry is accidental). Consider URL
|
||||
normalization (scheme/host case-fold, trailing-slash) plus an
|
||||
Authorization-shape check before comparing. Effort: S.
|
||||
- [ ] **P2 — claw-test --live runners inherit real HOME/XDG.** The grok /
|
||||
hermes / opencode --live runners run against the operator's real
|
||||
HOME/XDG config surface and only WARN on a pre-existing global gbrain
|
||||
entry; a scripted run can mutate or exercise the operator's live wiring.
|
||||
Consider a fail-closed flag (refuse when a global gbrain registration
|
||||
exists unless --allow-live-config) or hermetic-by-default across the
|
||||
runner family. Effort: M.
|
||||
- [ ] **P3 — fixed-name `.bak` parity: codex-toml.ts + hooks.ts writers.**
|
||||
opencode-json.ts now takes UNIQUE `.bak-<hex>` backups per operation
|
||||
(overlapping runs can't clobber each other's snapshot; harness restores
|
||||
from the returned path and unlinks on success). The codex TOML writer and
|
||||
the hooks settings writers still use fixed-name backups with the same
|
||||
theoretical overlap window — port the unique-backup pattern (and the
|
||||
restore-guard compare) for parity. Effort: S/M.
|
||||
## Dream triage cascade follow-ups (#4152, filed at implementation)
|
||||
|
||||
- [ ] **P2 — Incremental submit-drain + deadline threading in synthesize
|
||||
fan-out.** What: restructure the fan-out to submit bounded batches and
|
||||
drain each before submitting more, stopping against the parent job's
|
||||
`deadlineAtMs`. Why: today the phase bulk-submits every accepted child
|
||||
then drains sequentially inside `autopilot-cycle`'s 30-min wall clock
|
||||
(`handler-timeouts.ts:44`); a timeout mid-drain strands the remainder in
|
||||
the run's private queue (the C1 self-heal + retriage conversion now
|
||||
recover them, but not creating strands beats recovering them). Blocked
|
||||
by: `runCycle` does not thread deadline/abort into phases (verified
|
||||
absent at the synthesize call site, cycle.ts ~2030). Context: outside
|
||||
voice C2 on the #4152 eng review; the triage `max_ms` budget bounds the
|
||||
cheap half, this bounds the expensive half. Effort: M/L.
|
||||
- [ ] **P2 — Scheduled reject sample-audit with spend-posture
|
||||
integration.** What: automate `dream retriage --audit-rejects N` on a
|
||||
cadence (weekly cron or post-cycle sampling) writing disagreement-rate
|
||||
telemetry, gated by `spend.posture`. Why: the threshold is an
|
||||
intuition-set 0.5 until real false-negative data exists; the cascade
|
||||
literature is unanimous that unaudited gates drift (eng-review search
|
||||
check). The manual flag ships with #4152; this files the loop that runs
|
||||
without an operator remembering. Depends on: a few weeks of production
|
||||
score distributions. Effort: M.
|
||||
- [ ] **P3 — Borderline-band routing (0.30–0.49 → mid-tier model or batch
|
||||
digest).** What: a second lane where near-threshold files get a cheaper
|
||||
treatment instead of the binary keep/drop. Why: the issue marked it
|
||||
optional; it adds a third model lane + a second threshold pair, which
|
||||
should be tuned from `details.triage` score distributions rather than
|
||||
guessed. Blocked by: production calibration data (see the audit TODO
|
||||
above). Effort: M.
|
||||
- [ ] **P3 — Source×corpus multiplier: per-source corpus mapping or
|
||||
explicit fan-out consent.** What: `dream.synthesize.session_corpus_dir`
|
||||
is GLOBAL config while synth idempotency keys are SOURCE-namespaced, so
|
||||
N registered sources each re-fan the same corpus (a live deployment saw
|
||||
3 × ~1,250 jobs/day of the same files). Triage verdicts are
|
||||
source-agnostic (judged once) and the cascade cuts each source's fanout
|
||||
by the pass rate, but total synthesis is still N× the corpus. Why
|
||||
deferred: pages land per-source, so per-source synthesis may be intended
|
||||
semantics for some operators — needs its own issue + design (per-source
|
||||
corpus config keys vs an explicit multi-source consent flag). Diagnostic:
|
||||
`dream retriage --reconcile-queue --json` reports `queue.by_source`.
|
||||
Context: outside voice C3 argued root-cause-first; scoped out twice
|
||||
during the #4152 review. Comment on #4152 after ship. Effort: M.
|
||||
- [ ] **P3 — Dream triage perf follow-ups (from the #4152 ship review).**
|
||||
What: (a) batch the per-file `getDreamVerdict` PK probes in `runTriagePass`
|
||||
into one prefetch (unnest join on (file_path, content_hash)) and reuse it
|
||||
for retriage's spend-estimate loop (currently 2×N sequential roundtrips on
|
||||
the operator sweep); (b) a partial index for `countRecentSynthSubmissions`
|
||||
(`(created_at) WHERE name='subagent' AND idempotency_key LIKE
|
||||
'dream:synth-v2:%'`) so the opt-in daily cap's count is index-served on
|
||||
busy brains; (c) a shared `seedTriageVerdict` test helper to collapse the
|
||||
five hand-rolled triage-v1 seed blocks. Why: all flagged by the ship
|
||||
review's performance/maintainability specialists; none block — cache
|
||||
probes are ~0.1% of adjacent LLM latency and the cap is default-off.
|
||||
Effort: M.
|
||||
- [ ] **P3 — Per-file single-flight for triage cache misses.** What:
|
||||
concurrent passes (retriage while a cycle runs) can double-judge the same
|
||||
uncached file (~1¢/file, last-write-wins converges — benign but untidy);
|
||||
a per-(file,hash) advisory claim would dedupe. Why deferred: real locks
|
||||
are heavy machinery for a benign-cost race; the retriage help documents
|
||||
the behavior. Context: outside-voice CX5 on the #4152 ship review.
|
||||
Effort: M.
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"gray-matter": "^4.0.3",
|
||||
"heic-decode": "^2.1.0",
|
||||
"js-yaml": "^3.15.1",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"marked": "^18.0.2",
|
||||
"openai": "^4.0.0",
|
||||
"pgvector": "^0.2.0",
|
||||
@@ -469,6 +470,8 @@
|
||||
|
||||
"json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
|
||||
|
||||
"jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="],
|
||||
|
||||
"kind-of": ["kind-of@6.0.3", "", {}, "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="],
|
||||
|
||||
"libheif-js": ["libheif-js@1.19.8", "", {}, "sha512-vQJWusIxO7wavpON1dusciL8Go9jsIQ+EUrckauFYAiSTjcmLAsuJh3SszLpvkwPci3JcL41ek2n+LUZGFpPIQ=="],
|
||||
|
||||
+5
-1
@@ -23,4 +23,8 @@ timeout = 60_000
|
||||
# were appending rows into the operator's real ~/.gbrain/sync-failures.jsonl,
|
||||
# which `gbrain doctor` reads and warns on. See
|
||||
# test/helpers/sync-failures-preload.ts.
|
||||
preload = ["./test/helpers/legacy-embedding-preload.ts", "./test/helpers/audit-dir-preload.ts", "./test/helpers/sync-failures-preload.ts"]
|
||||
# #3485: database-url-guard-preload runs FIRST — it refuses to start the run
|
||||
# while DATABASE_URL/GBRAIN_DATABASE_URL is ambient without the explicit
|
||||
# GBRAIN_TEST_ALLOW_DATABASE_URL=1 opt-in that the e2e wrappers set at their
|
||||
# own subprocess boundary. See test/helpers/database-url-guard-preload.ts.
|
||||
preload = ["./test/helpers/database-url-guard-preload.ts", "./test/helpers/legacy-embedding-preload.ts", "./test/helpers/audit-dir-preload.ts", "./test/helpers/sync-failures-preload.ts"]
|
||||
|
||||
+6
-1
@@ -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)
|
||||
|
||||
@@ -99,6 +99,11 @@ Per-client setup guides live in [`docs/mcp/`](mcp/):
|
||||
- [`docs/mcp/CLAUDE_DESKTOP.md`](mcp/CLAUDE_DESKTOP.md)
|
||||
- [`docs/mcp/CHATGPT.md`](mcp/CHATGPT.md)
|
||||
- [`docs/mcp/PERPLEXITY.md`](mcp/PERPLEXITY.md)
|
||||
- [`docs/mcp/HERMES.md`](mcp/HERMES.md) — Hermes (Nous Research CLI)
|
||||
- [`docs/mcp/GROK.md`](mcp/GROK.md) — Grok Build (xAI CLI)
|
||||
- [`docs/mcp/OPENCODE.md`](mcp/OPENCODE.md) — opencode (opencode.ai / SST terminal agent)
|
||||
- [`docs/mcp/OPENCLAW.md`](mcp/OPENCLAW.md) — OpenClaw (bundle plugin or stdio)
|
||||
- [`docs/mcp/CLAUDE_COWORK.md`](mcp/CLAUDE_COWORK.md) — Claude Cowork (team plan)
|
||||
- [`docs/mcp/DEPLOY.md`](mcp/DEPLOY.md) — production deploy patterns
|
||||
|
||||
The HTTP server ships with an admin SPA at `/admin`, an SSE activity feed at `/admin/events`, DCR-style client registration, scope-gated `read`/`write`/`admin` access, and rate limiting.
|
||||
|
||||
+150
-7
@@ -7,17 +7,76 @@ only.
|
||||
|
||||
### Test command tiers
|
||||
|
||||
Seven test command tiers, each with a clear scope:
|
||||
Six test command tiers, each with a clear scope:
|
||||
|
||||
| Command | What it runs | Wallclock | When to use |
|
||||
|---|---|---|---|
|
||||
| `bun run test` | Parallel unit-test fast loop. Sharded fan-out via `scripts/run-unit-parallel.sh` (default 4 shards — CPU-detected, clamped to a max of 8; 4 matches CI's fan-out and avoids PGLite WASM-init contention), then a serial pass over `*.serial.test.ts`. Excludes `*.slow.test.ts` and `test/e2e/*`. No pre-checks, no typecheck. Memory-safe by default: total concurrency (shards × intra-shard files) is capped to available memory at `GBRAIN_TEST_MEM_PER_FILE_MB` (default 1536 — a PGLite WASM instance) per concurrent file, and two phantom-failure classes are automatically re-run serially (the rescue pass): failures carrying the WASM out-of-memory signature, and shards killed externally (SIGTERM/SIGKILL well before the shard timeout — sibling workspaces' process cleanup, memory jetsam). Phantoms pass serially and the run goes green with an `oom_rescued` note; real failures fail again serially and stay red. Knobs: `GBRAIN_TEST_NO_MEM_ADAPT=1`, `GBRAIN_TEST_NO_OOM_FALLBACK=1`, `GBRAIN_TEST_MAX_CONCURRENCY` (intra-shard, default 4), `GBRAIN_TEST_SHARD_TIMEOUT` / `GBRAIN_TEST_SHARD_KILL_AFTER`, plus `--shards N` / `--max-concurrency N` / `--dry-run` script args. | a few minutes on a Mac dev box | Inner edit loop. Default. |
|
||||
| `bun run test` | Parallel unit-test fast loop. Sharded fan-out via `scripts/run-unit-parallel.sh` (default 4 shards — CPU-detected, clamped to a max of 8; 4 matches CI's fan-out and avoids PGLite WASM-init contention), then a serial pass over `*.serial.test.ts`. Excludes `*.slow.test.ts` and `test/e2e/*`. No pre-checks, no typecheck. Builds/refreshes the PGLite schema snapshot BEFORE the shard fan-out and exports `GBRAIN_PGLITE_SNAPSHOT` so PGLite-booting files restore a baked schema instead of replaying every migration (~10x wallclock on a full run; see "PGLite schema snapshot" below). Opt out: `GBRAIN_NO_SNAPSHOT=1`. Memory-safe by default: total concurrency (shards × intra-shard files) is capped to available memory at `GBRAIN_TEST_MEM_PER_FILE_MB` (default 1536 — a PGLite WASM instance) per concurrent file, and two phantom-failure classes are automatically re-run serially (the rescue pass): failures carrying the WASM out-of-memory signature, and shards killed externally (SIGTERM/SIGKILL well before the shard timeout — sibling workspaces' process cleanup, memory jetsam). Phantoms pass serially and the run goes green with an `oom_rescued` note; real failures fail again serially and stay red. Knobs: `GBRAIN_TEST_NO_MEM_ADAPT=1`, `GBRAIN_TEST_NO_OOM_FALLBACK=1`, `GBRAIN_TEST_MAX_CONCURRENCY` (intra-shard, default 4), `GBRAIN_TEST_SHARD_TIMEOUT` / `GBRAIN_TEST_SHARD_KILL_AFTER`, plus `--shards N` / `--max-concurrency N` / `--dry-run` script args. | a few minutes on a Mac dev box | Inner edit loop. Default. |
|
||||
| `bun run verify` | CI's authoritative pre-test gate set, fanned out in parallel by `scripts/run-verify-parallel.sh`: the full `check:*` battery (privacy, jsonb, progress, source-id, test-isolation, wasm, …) plus `bun run typecheck`. The `CHECKS` array in that script is the single source of truth — CI literally calls `bun run verify` in a dedicated job. | ~16s (parallel; typecheck dominates) | Before pushing; before `/ship`. |
|
||||
| `bun run test:full` | `verify && bun run test && bun run test:slow && [smart e2e]`. The local equivalent of "everything CI runs." Smart e2e: runs e2e only when `DATABASE_URL` is set; else loud skip notice to stderr. | ~3-5min depending on slow + e2e | Pre-merge sanity, before opening a PR. |
|
||||
| `bun run test:slow` | Just the `*.slow.test.ts` set (intentional cold-path correctness checks). | seconds-to-minutes | When touching slow-path code. |
|
||||
| `bun run test:serial` | Just the `*.serial.test.ts` set (cross-file-contention quarantine; one bun process per file for true module-registry isolation). | ~1s per quarantined file | Debugging a specific quarantined file. |
|
||||
| `bun run test:e2e` | Real Postgres E2E. Requires Docker + `DATABASE_URL`. Sequential. | ~5-10min | Pre-ship; nightly. |
|
||||
| `bun run check:all` | The historical pre-check scripts (chained sequentially in package.json). Overlaps `verify` heavily but is NOT a superset — `verify`'s `CHECKS` array in `scripts/run-verify-parallel.sh` is the authoritative gate; `check:all` keeps a few local-only extras (trailing-newline, exports-count, no-legacy-getconnection). | ~10s | Local-only sweep for the extras. |
|
||||
|
||||
There is no `check:all` script anymore — it was a second, hand-synced guard
|
||||
registry that drifted from `verify` (three checks were reachable ONLY from it,
|
||||
i.e. never ran anywhere). The `CHECKS` array in `scripts/run-verify-parallel.sh`
|
||||
is the single execution list, and it now includes the former `check:all`-only
|
||||
extras (`check:newlines`, `check:exports-count`, `check:no-legacy-getconnection`).
|
||||
The guard REGISTRY is `scripts/guards-manifest.tsv` (see "Guard registry and
|
||||
self-test" below).
|
||||
|
||||
### PGLite schema snapshot (default-on)
|
||||
|
||||
`scripts/build-pglite-snapshot.ts` (`bun run build:pglite-snapshot`) bakes a
|
||||
post-`initSchema()` PGLite data dir into `test/fixtures/pglite-snapshot.tar`
|
||||
plus a version file; `PGLiteEngine.initSchema()` restores the tar instead of
|
||||
replaying the embedded schema + all migrations when the env var
|
||||
`GBRAIN_PGLITE_SNAPSHOT` points at it. Both `bun run test`
|
||||
(`scripts/run-unit-parallel.sh`, before the shard fan-out) and
|
||||
`scripts/ci-local.sh` call the builder unconditionally and export the env var.
|
||||
Measured effect: a full parallel suite run drops ~10x (PGLite-booting files go
|
||||
~1.63s → ~0.91s each). Properties:
|
||||
|
||||
- **Idempotent.** A hash short-circuit exits in ~40ms when the snapshot is
|
||||
fresh, and REBUILDS a stale one. The hash covers `PGLITE_SCHEMA_SQL`, every
|
||||
migration's `sql` + `sqlFor.pglite`, AND each migration `handler`'s function
|
||||
source (`Function.prototype.toString`) — 19+ migrations carry executable
|
||||
handler code with empty `sql` that a sql-only hash cannot see.
|
||||
- **Concurrency-safe.** Parallel shard runners / sibling workspaces serialize
|
||||
on an atomic `mkdir` lock (`test/fixtures/.pglite-snapshot.lock`) with
|
||||
staleness-verified takeover of a crashed builder; the tar is written first
|
||||
and the version file last, so a crash can never leave a fresh-looking torn
|
||||
fixture. `GBRAIN_SNAPSHOT_LOCK_TIMEOUT_MS` (default 120000) bounds the
|
||||
waiter; an exhausted waiter facing a still-live lock proceeds unlocked as a
|
||||
last resort (the loader gate below validates the version file, not the tar
|
||||
bytes).
|
||||
- **Never authoritative.** The loader (`tryLoadSnapshot` in
|
||||
`src/core/pglite-engine.ts`) verifies the schema hash AND the embedding
|
||||
shape the snapshot was baked with (`dims=` / `model=` lines in the version
|
||||
file) against what this process would create; any mismatch — including a
|
||||
version file without shape lines — warns once and falls through to normal
|
||||
cold init. A wrong fixture can never poison the suite.
|
||||
- **Opt out.** `GBRAIN_NO_SNAPSHOT=1` skips the build + env export for a run;
|
||||
the migration-replay canary tests clear the env themselves regardless.
|
||||
|
||||
Pinned by `test/snapshot-shape-guard.test.ts` (hash + shape refusal matrix,
|
||||
handler-source hash sensitivity).
|
||||
|
||||
### Guard registry and self-test
|
||||
|
||||
`scripts/guards-manifest.tsv` is THE single registry of `scripts/check-*`
|
||||
guards (currently 45), each classified `scanner` (greps/parses repo sources —
|
||||
must eventually carry fixtures), `buildfresh`, or `repostate` (build/freshness
|
||||
guards are exempt-with-reason, not fixture-tested).
|
||||
`scripts/guard-self-test.sh` (`bun run check:guard-self-test`, wired into
|
||||
`bun run verify`) proves every `selftest=yes` scanner CAN fail: it runs each
|
||||
one against known-bad (must exit non-zero) and known-good (must pass) fixture
|
||||
trees under `test/fixtures/guards/<guard>/{bad,good}/` via the
|
||||
`GBRAIN_GUARD_ROOT` env seam, and enforces manifest completeness — a new
|
||||
`scripts/check-*` script that isn't registered in the manifest fails the
|
||||
build. A guard whose pattern rots into a permanently-green no-op now fails CI
|
||||
instead of masquerading as coverage.
|
||||
|
||||
### Shell dispatch and Windows
|
||||
|
||||
@@ -73,10 +132,32 @@ Triage rule: a `warn-pass` EXIT-HANG line in `.context/test-summary.txt` is NOT
|
||||
- `*.test.ts` → fast loop (parallel up-to-4-shard fan-out, memory-adaptive).
|
||||
- `*.slow.test.ts` → run via `bun run test:slow` only (intentional cold-path tests; would dominate the fast loop's wallclock).
|
||||
- `*.serial.test.ts` → run via `bun run test:serial` after the parallel pass completes; one bun process per file (`--max-concurrency=1` within a shared process is not enough — the module registry still leaks `mock.module`). Quarantine for tests that share file-wide state and race when run alongside other files in the same `bun test` process. Several dozen files, discovered by the `*.serial.test.ts` glob — no list to maintain. Typical residents: `mock.module(...)` users (top-level mocks leak across files in a shard process, e.g. `test/embed.serial.test.ts`), env-coupled files (e.g. `test/brain-registry.serial.test.ts`), and process-lifecycle suites that assert on `process.exitCode` (e.g. `test/pglite-engine-disconnect.serial.test.ts`). **Do not put the parallelism back on a serial file unless you've fixed the contention root cause** (it just re-introduces the flake).
|
||||
- `test/e2e/*.test.ts` → real-Postgres E2E. Skipped when `DATABASE_URL` is unset.
|
||||
- `test/e2e/*.test.ts` → real-Postgres E2E. Skipped when `DATABASE_URL` is unset. One out-of-directory file rides this lane: `test/phantom-redirect-engine-parity.test.ts` (lives in `test/` for its PGLite arm, but its Postgres arm is only reachable through a DATABASE_URL-bearing lane — the unit wrappers strip the URL per #3485, so `run-e2e.sh`'s no-args list and CI's parity job carry it).
|
||||
- `tests/heavy/*.sh` → ops-shape shell scripts. Cost minutes per run; NOT in default `bun test`. Run via `bun run test:heavy` or scheduled nightly via `.github/workflows/heavy-tests.yml`. Examples: pg_upgrade matrix (boot legacy brain → walk to head), RSS budget gate (measure peak worker RSS vs committed baseline), read-latency-under-sync (p50/p95/p99 under concurrent writer load), sync lock regression (N concurrent syncs assert 1 winner + N-1 lock-busy + zero leaked `gbrain_cycle_locks` rows). See `tests/heavy/README.md` for when to add a script here vs `*.slow.test.ts`. Files prefixed with `_` (e.g. `tests/heavy/_build_legacy_fixtures.sh`) are helpers/libs invoked by sibling tests — the runner skips them.
|
||||
- `test/fuzz/*.test.ts` → property-based fuzz harness. Pure-validator targets in `pure-validators.test.ts` are guarded by `scripts/check-fuzz-purity.sh` (in `bun run verify`), which `bun build --target=bun` bundles each target and greps the resulting bundle for banned transitive imports (`node:fs`, `node:child_process`, engine modules). Anything that fails the guard moves to `mixed-validators.test.ts` (still property-tested, but no purity guarantee) or `filesystem-validators.test.ts` (fs-backed, uses temp dirs). Fuzz tests run in the default `bun test` loop because they're fast (~3s for ~12 properties × 1000 runs each).
|
||||
|
||||
### TTY and interactive-CLI testing
|
||||
|
||||
Four escalating tools; reach for the cheapest one that answers the question:
|
||||
|
||||
| Question | Tool | Example |
|
||||
|---|---|---|
|
||||
| Does the TTY/non-TTY branch logic pick right? | Inject `isTTY` into the pure function — no subprocess | `test/init-provider-picker.test.ts`, `test/jobs-watch-mode.test.ts` |
|
||||
| Does the real CLI behave right when stdin is NOT a terminal? | Spawn the CLI with piped/ignored stdio | `test/cli-stdin-hang.test.ts` (fast loop); `test/e2e/init-fresh-pglite.test.ts` (manual `test:e2e` lane — see the TODOS e2e CI-lane entry) |
|
||||
| Does the real CLI render menus and read typed input under a REAL terminal? | `launchTty` from `test/helpers/tty-harness.ts` in a `*.serial.test.ts` file | `test/init-picker-pty.serial.test.ts` |
|
||||
| How does the install FEEL (stalls, copy, silence windows)? | `scripts/dx-explore.ts` — instrument, not a test; nothing asserts | transcripts under `.context/dx-runs/` (see `docs/guides/bootstrap.md`) |
|
||||
|
||||
Real-PTY test rules: put the file in the serial lane (`*.serial.test.ts` — that
|
||||
lane runs in required CI; a new `test/e2e/*` file does NOT, since unit shards
|
||||
exclude the directory and the e2e workflow runs only explicitly named files,
|
||||
no glob);
|
||||
assert NON-default picker values (bare Enter and each prompt's 60s
|
||||
`readLineSafe` timeout both resolve to the default, so a defaults-asserting
|
||||
test passes with dead input); always `await session.close()` in a `finally`
|
||||
(only `close()` clears the harness wall timer); and point `HOME` plus
|
||||
`GBRAIN_HOME` at a temp root with pass-through auth keys stripped via
|
||||
`dropEnv` so picker state is machine-independent.
|
||||
|
||||
### Skills-manifest freshness guard
|
||||
|
||||
`skills/skills.lock.json` is a committed sha256 inventory of every bundled file under
|
||||
@@ -90,7 +171,7 @@ Any change under `skills/` must regenerate it: `bun run scripts/generate-skills-
|
||||
|
||||
**This section is the canonical home of the test-isolation discipline** — CONTRIBUTING.md and other docs link here rather than restating the rules.
|
||||
|
||||
The cross-file flake class is enforced statically by `scripts/check-test-isolation.sh`, wired into `bun run verify` and `bun run check:all`. Rules (non-serial unit files only; `*.serial.test.ts` and `test/e2e/*` are skipped):
|
||||
The cross-file flake class is enforced statically by `scripts/check-test-isolation.sh`, wired into `bun run verify`. Rules (non-serial unit files only; `*.serial.test.ts` and `test/e2e/*` are skipped):
|
||||
|
||||
| Rule | What it bans | Fix |
|
||||
|---|---|---|
|
||||
@@ -161,6 +242,35 @@ The quarantine has grown to dozens of files — treat it as debt: every addition
|
||||
|
||||
`bun test` runs all tests without a database. E2E tests skip gracefully when `DATABASE_URL` is not set.
|
||||
|
||||
**Database-URL run guard (#3485).** A `bun test` invocation REFUSES to start while
|
||||
`DATABASE_URL` or `GBRAIN_DATABASE_URL` is ambient in the environment, because some
|
||||
tests run destructive SQL against whatever those URLs point at (a bare `bun test`
|
||||
with `~/.gbrain/.env` sourced has wiped a real brain). The guard is a bunfig
|
||||
`[test]` preload (`test/helpers/database-url-guard-preload.ts`); it hard-fails with
|
||||
instructions rather than silently unsetting (a silent unset would turn
|
||||
DATABASE_URL-gated e2e tests into green skips). The e2e wrappers
|
||||
(`scripts/run-e2e.sh`, the e2e/heavy workflows) opt in at their own boundary via
|
||||
`GBRAIN_TEST_ALLOW_DATABASE_URL=1`; the unit/slow wrappers instead strip both
|
||||
URL vars at their boundary (unit tests need no database), which keeps
|
||||
`bun run test:full` working with DATABASE_URL exported. Caveat: bun loads
|
||||
`bunfig.toml` from the invocation cwd, so the preload layer only applies to
|
||||
runs started at the repo root — the per-file name floor below is the layer
|
||||
that doesn't care about cwd. Two more layers apply after the opt-in: every
|
||||
test that runs destructive SQL on the ambient URL must call
|
||||
`assertSafeE2eDatabaseUrl()` (`test/helpers/db-guard.ts` — name floor: the database
|
||||
name must contain "test" as a segment, or be opted in via `GBRAIN_E2E_ALLOW_DB`)
|
||||
or carry an inline name floor the coverage gate recognizes
|
||||
(`test/e2e/schema-drift.test.ts` keeps its own `looksLikeTestDb`, deliberately
|
||||
different because it also accepts `*_e2e`), and `test/db-guard-coverage.test.ts`
|
||||
statically scans the suite and fails when a file connects to `DATABASE_URL` and
|
||||
runs destructive SQL unguarded. The heavy shell lane gets the same floor outside
|
||||
bun: `tests/heavy/_db_floor.sh` (sourced by `scripts/run-heavy.sh` for the whole
|
||||
lane, and by each database-touching heavy script itself, since scripts are
|
||||
documented for direct invocation — the PGLite-based heavy scripts unset the URL
|
||||
instead) checks BOTH URL variables and strips query strings before extracting
|
||||
the database name, so a `?host=/tmp/test-sockets` parameter can't smuggle a
|
||||
test-shaped segment past it.
|
||||
|
||||
Unit tests and what they cover:
|
||||
|
||||
- `test/markdown.test.ts` — frontmatter parsing; `splitBody` sentinel precedence, horizontal-rule preservation, `inferType` wiki subtypes.
|
||||
@@ -175,7 +285,10 @@ Unit tests and what they cover:
|
||||
- `test/volunteer-context.test.ts` — push-based context core (#2095), hermetic in-memory PGLite: `parseWindow` lenient `user:`/`assistant:` parsing, multi-turn window extraction, confidence-gated volunteering (arm confidences, multi-turn/newest-turn boosts, `min_confidence` gate, max-pages cap), slug-only suppression, privacy (rationales are deterministic templates; synopses pass the takes/facts fence), and the approximate usage-stats join.
|
||||
- `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/init-picker-pty.serial.test.ts` — the interactive `gbrain init` pickers (embedding-provider + search-mode) driven under a REAL pseudo-terminal via `launchTty`: typed input lands (a NON-default mode choice verified by a follow-up non-TTY config read — bare Enter and the `readLineSafe` timeout both resolve to defaults, so a defaults-asserting test would pass with dead input), prompt-to-acknowledgement gaps bounded well under the fallback window, plus the Ctrl-D/EOF keyless fallback. On CI, missing PTY support fails loud instead of skipping. Hermetic: HOME + GBRAIN_HOME at a temp root, pass-through auth keys stripped via `dropEnv`; `session.close()` in `finally`. Serial: PTY spawn + full PGLite bootstrap, and the serial lane is what runs in required CI.
|
||||
- `test/tty-harness.test.ts` — the real-PTY harness's pure helpers (`stripAnsi`, `computeStalls`, `renderStallsReport`, `parseDriveCommand`, `buildClaudeTuiSeed`) with zero subprocesses; the file's live-PTY smokes are `describe.skipIf(!ptySupported())`-gated.
|
||||
- `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.
|
||||
@@ -221,6 +334,18 @@ Unit tests and what they cover:
|
||||
- `test/enrichment-service.test.ts` — entity slugification, extraction, tier escalation.
|
||||
- `test/data-research.test.ts` — recipe validation, MRR/ARR extraction, dedup, tracker parsing, HTML stripping.
|
||||
- `test/minions.test.ts` — Minions job queue: CRUD, state machine, backoff, stall detection, dependencies, worker lifecycle, lock management, claim mechanics, depth/child-cap, timeouts, cascade kill, idempotency, `child_done` inbox, attachments, removeOnComplete/Fail, `max_stalled` clamp/default/plumbing coverage.
|
||||
- `test/minion-queue-renewlock-signal.test.ts` — `renewLock` forwards its optional AbortSignal to `executeRawDirect` (stub-engine capture); legacy 3-arg calls unchanged; token-fence miss returns false.
|
||||
- `test/cycle-drain-renewal.test.ts` — `runDrainRenewalTick` (cycle drain): per-call signal aborted on timeout (slot released), onLost once on a lost fence, throws swallowed, hung renewal resolves at the deadline.
|
||||
- `test/queue-probe-cancellation.test.ts` — `probeQueueState`/`queryWedgeSignals` signal threading: the 1500ms budget CANCELS the losing probe query; fast-path signals never abort; throw still collapses to `{probe_failed: true}`.
|
||||
- `test/db-pool-max-lifetime.test.ts` — `resolveMaxLifetimeSeconds`: env forms, 0-disables, 30–60min jitter bounds, warn-once on invalid, per-call jitter variance.
|
||||
- `test/pool-gauge.test.ts` — `CheckoutGauge` pure semantics + the PostgresEngine seams with fake pools: counted while in flight, released on resolve, on REJECTED queries, and on the SYNCHRONOUS pre-aborted-signal throw (leak guards); `getPoolDiagnostics` fail-open.
|
||||
- `test/db-probe.test.ts` — `runDbProbe` verdict matrix (pool_starved / server_unreachable / unknown), honest-disjunction + no-waiter-arithmetic wording pins, hung probes cancelled via their signals, diagnostics absent/throwing fail open.
|
||||
- `test/postgres-engine-reserved-routing.test.ts` — `withReservedConnection` routing: direct pool when dual-pool active, read pool when kill-switched/in-tx, semaphore cap (directPoolSize−1) with read-pool overflow, permit released on fn throw and reserve failure.
|
||||
- `test/job-isolation-protocol.test.ts` — outcome-file codec round-trip + every decode failure path (missing/malformed/oversize→UnrecoverableError; byte counts, never content), handler-error instanceof reconstruction, child-CLI invocation resolution, and REAL detached-process `killProcessGroup` tests incl. the grandchild-death guarantee (exercises the Bun negative-pid `/bin/kill` fallback for real under `bun test`).
|
||||
- `test/run-child-entry.test.ts` — `runChildJobEntry` on real in-memory PGLite with a REAL claim-minted token: success (fenced updateProgress lands), handler-failure outcome (exit 0), token-mismatch never runs the handler (exit 14), missing job/handler, parent-death watchdog aborts a live handler.
|
||||
- `test/child-job-runner.test.ts` — `runJobInChild` against real .mjs children: success + full env contract (incl. `GBRAIN_DIRECT_POOL_SIZE=1`), error/lease outcome reconstruction, crash, SIGTERM-ignorer → group SIGKILL at the injected grace, pre-aborted signal, spawn ENOENT → `ChildSpawnInfraError`, worker-shutdown drain (report-during-drain completes; non-reporting kill → `ChildWorkerShutdownError`).
|
||||
- `test/worker-job-isolation.test.ts` — full parent path on PGLite with the `fake-run-child.mjs` fixture: claim → child → fenced completeJob (real token over env), error outcome → failJob, crash burns the attempt, spawn failure RELEASES with zero attempts burned, and the codex-2 #8 serialization-parity pin (unreportable results fail in BOTH modes, never falsely complete).
|
||||
- `test/jobs-isolation-flag.test.ts` — `parseJobIsolationFlag`: space/= forms, env fallback + flag-wins, empty-env default, other flags untouched.
|
||||
- `test/extract.test.ts` — link extraction, timeline extraction, frontmatter parsing, directory type inference.
|
||||
- `test/extract-db.test.ts` — `gbrain extract --source db`: typed link inference, idempotency, `--type` filter, `--dry-run` JSON output.
|
||||
- `test/extract-fs.test.ts` — `gbrain extract --source fs`: first-run inserts + second-run reports zero, dry-run dedups candidates across files, second-run perf regression guard for the N+1 dedup bug.
|
||||
@@ -263,10 +388,19 @@ Unit tests and what they cover:
|
||||
- `test/longmemeval-sanitize.test.ts` — sanitization parity pinning that `INJECTION_PATTERNS` from `src/core/think/sanitize.ts` is the single source of truth (adding a pattern there must cover both `<take>` framing and `<chat_session>` framing, no per-surface regex drift).
|
||||
- `test/openai-compat-multimodal.test.ts` — gateway's openai-compatible multimodal path: happy-path single + multi-input embedding, unauthenticated proxy mode, dimension-mismatch guard (throws `AIConfigError` with model id + observed + expected pre-storage), default-dim fallback when recipe declares `default_dims`, HTTP 401 / 400 / malformed-JSON / non-array error paths, regression that the existing Voyage `/multimodalembeddings` recipe still routes through its dedicated path. Hermetic via the `__setEmbedTransportForTests` seam.
|
||||
- `test/serve-stdio-lifecycle.test.ts` — `MCP_STDIO=1` env guard: stdin EOF does NOT trigger shutdown when the env is set, SIGTERM still does (guard scope is correct), unset env preserves the CLI lifecycle. Exercises the `ServeOptions.mcpStdio?: boolean` test seam directly so tests don't mutate `process.env`.
|
||||
- `test/db-lock-fencing.test.ts` — fenced lock identity: a `DbLockHandle` carries its acquisition fence, `refresh()` returns true while owned and false after a steal (0-row fenced UPDATE), a stolen-from handle's `release()` is a fenced no-op that leaves the successor's row intact, and `startCycleLockRefresher` aborts its controller with `LockStolenError` on a fenced miss while serializing ticks (a slow refresh never overlaps the next).
|
||||
- `test/cycle-lock-steal.serial.test.ts` — runCycle steal-abort arc end-to-end: a mid-run steal produces a structured partial report (`reason: 'lock_stolen'`), runs no further phases, and never touches the successor's lock row; a steal-free cycle completes and releases normally.
|
||||
- `test/cycle-any-abort-signal.test.ts` — `anyAbortSignal` combining: pre-aborted inputs, late aborts propagating their reason, duck-typed signal stubs (no `addEventListener`) observed via poll, and `dispose()` detaching the caller-signal listener + clearing the poll timer (the daemon leak class).
|
||||
- `test/queue-stall-parent-unblock.test.ts` — the shared `killJobs` tail: a stall-exhausted child lands `child_done(dead)` in its parent's inbox and unblocks the parent, a requeued child doesn't touch the parent, all three reapers route through the tail with their own outcome, and the idempotent stranded-parent sweep self-heals parents whose children were already dead (without unblocking parents that still have a live child).
|
||||
- `test/queue-started-at-retry.test.ts` — every automatic re-run path clears `started_at` (failJob delayed branch, stall requeue, lease release, promoteDelayed, parent re-claim) so a retried job's wall-clock budget measures execution, not backoff wait; end-to-end survival of the wall-clock sweep on a fresh attempt.
|
||||
- `test/embed-modality-preserved.test.ts` — `carryChunkMetadata` carries modality + all code-metadata fields through re-embed merges (an image chunk stays image), plus the write-side contract that omitting modality resets it to text (why the shared list is load-bearing).
|
||||
- `test/import-abort-error.test.ts` — `runImport` preflight/argv failures throw typed `ImportAbortError` instead of exiting the process; the calling process survives the abort.
|
||||
- `test/lint-fix-single-pass.test.ts` — `gbrain lint --fix` walks the tree once and `total_fixed` reports the fixes THIS run applied.
|
||||
- `test/snapshot-shape-guard.test.ts` — PGLite snapshot loader refusal matrix: shape-less version files, dims/model mismatches, and stale schema hashes are all refused; matching hash + shape loads; a migration-handler edit changes the hash.
|
||||
|
||||
### E2E test inventory
|
||||
|
||||
E2E tests live in `test/e2e/` and run against real Postgres+pgvector (require `DATABASE_URL`), except where noted as PGLite in-memory (no `DATABASE_URL` needed).
|
||||
E2E tests live in `test/e2e/` and run against real Postgres+pgvector (require `DATABASE_URL`), except where noted as PGLite in-memory (no `DATABASE_URL` needed). One file outside the directory also rides the e2e lane: `test/phantom-redirect-engine-parity.test.ts` (Postgres arm; see the file taxonomy above).
|
||||
|
||||
- `bun run test:e2e` runs Tier 1 (mechanical, all operations, no API keys). Includes dedicated cases for the postgres-engine `addLinksBatch` / `addTimelineEntriesBatch` bind path — postgres-js's JSONB bind (`jsonb_to_recordset(($1::jsonb)->'rows')`) differs from PGLite's and gets its own coverage.
|
||||
- `test/e2e/search-quality.test.ts` — search quality against PGLite (no API keys, in-memory).
|
||||
@@ -278,10 +412,18 @@ E2E tests live in `test/e2e/` and run against real Postgres+pgvector (require `D
|
||||
- `test/e2e/sync.test.ts` — `--skip-failed` failure-loop test alongside happy-path tests: broken file → `performSync` returns `blocked_by_failures` with grouped breakdown → `performSync({skipFailed: true})` advances bookmark and returns `AcknowledgeResult` with code summary → second broken file → second cycle. Saves and restores the user's real `~/.gbrain/sync-failures.jsonl` so the test is hermetic. Asserts bookmark gating, JSONL state, dedup across paths, summary aggregation, and the literal doctor-rendering string format.
|
||||
- `test/e2e/upgrade.test.ts` — check-update against real GitHub API (network required).
|
||||
- `test/e2e/minions-shell-pglite.test.ts` — PGLite `--follow` inline shell-job path (in-memory, no `DATABASE_URL` required) — the path the minion-orchestrator skill documents for dev use.
|
||||
- `test/e2e/job-isolation.test.ts` — process isolation on real Postgres (DATABASE_URL-gated, wired EXPLICITLY into `.github/workflows/e2e.yml` tier1 — the workflow runs only named files): a concurrency-3 isolated drain through real child processes (the `fake-run-child.mjs` fixture — real spawns, no child DB pools), and the REAL `jobs run-child` CLI entrypoint end-to-end (engine bootstrap incl. the child's own pools, quiet handler registry, token validation, outcome protocol).
|
||||
- `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/install-real-grok.serial.test.ts` — the grok "door" (xAI Grok Build; every asserted shape observed against the pin in `docs/mcp/GROK-CLI-PIN.md`). SPLIT-GATED, a deliberate divergence from the hermes door: grok's `mcp add/list/doctor` run keyless, so the compat tier (version-shape pin, documented-shape `grok mcp add gbrain -- gbrain serve --surface verbs` via a PATH-staged bin dir, saved-TOML asserts via `Bun.TOML.parse`, `mcp doctor` handshake proving the seven-verb surface, vendor-fallback provenance guard, direct-TOML surface) needs only `GBRAIN_REAL_GROK_E2E=1` + a resolvable binary; the paid SMOKE additionally needs a non-empty `XAI_API_KEY` and asserts a PER-RUN NONCE fact (grok has fs/shell tools — the committed fact is greppable, so recall of it proves nothing) with web search disabled. `mcp add` is lazy (exit 0 always) — `mcp doctor <name> --json` is the honest discriminator (exit 0/1 observed). Hermetic HOME + GROK_HOME + tmp cwd on every spawn (grok reads vendor MCP configs for trusted folders and loads `.envrc` from cwd); bounded tripwire over the operator's real `~/.grok` config/credential files (volatile paths excluded — grok rewrites logs/sessions/bin/docs every run) + a checkout guard that no `.grok/`/`.mcp.json` appeared in the repo root. Venue: heavy-tests.yml (`real-agent-e2e` + `grok-door` jobs); run directly via `GBRAIN_REAL_GROK_E2E=1 bun test test/e2e/install-real-grok.serial.test.ts`.
|
||||
- `test/e2e/install-real-opencode.serial.test.ts` — the opencode "door" (SST opencode; every asserted shape observed against the pin in `docs/mcp/OPENCODE-CLI-PIN.md`). SPLIT-GATED a step past the grok door: opencode's anonymous FREE TIER drives MCP tool calls keyless, so even the nonce SMOKE runs in the keyless tier — T1 bare-semver version pin (the SST-vs-claimant discriminator), T2 documented-shape `opencode mcp add gbrain --env … -- gbrain serve --surface verbs` + the honest `opencode mcp list` discriminator (it SPAWNS every server; `✓/✗` text is the assertion surface — exit code is 0 even on failure, and `mcp debug` is OAuth-only), T2b spawn-gate CANARY (a project-config decoy is spawn-attempted with NO trust prompt — if this ever gates, the bootstrap user-global scope default's rationale changed: re-observe), T3 writer parity (gbrain's `opencode-json.ts` output handshakes through the real binary; cross-tool preservation both ways), T4 keyless SMOKE (per-run nonce + STRUCTURAL `gbrain_*` tool_use proof via `parseOpencodeJsonl`, `--format json`). The paid T5 anthropic leg additionally needs a non-empty `ANTHROPIC_API_KEY` and self-validates the pinned model id against the authed `opencode models` list BEFORE any spend. Hermetic HOME + both XDG dirs + tmp cwd on every spawn; `--pure` on every probe (`mcp list` autoloads plugins — a code-execution surface); bounded tripwire over the operator's real opencode configs/auth.json + a repo-root checkout guard. Venue: heavy-tests.yml (`real-agent-e2e` + `opencode-door` jobs, plus the schedule-only `opencode-door-canary` latest-version leg — continue-on-error, a pin-refresh signal, never a gate); run directly via `GBRAIN_REAL_OPENCODE_E2E=1 bun test test/e2e/install-real-opencode.serial.test.ts`.
|
||||
|
||||
**Door cadence policy** (adopted with the 4th door agent): the NEWEST door agent runs at nightly/schedule cadence (currently opencode, whose canary leg also tracks `latest`); a door drops to label-only (`real-agent-e2e`) after 2 stable monthly cycles with unchanged pins. Rationale: churn concentrates in the newest integration; steady-state doors pay for themselves on demand, not nightly.
|
||||
- `test/helpers/tty-harness.ts` + `test/tty-harness.test.ts` — the DX real-PTY harness (`Bun.spawn({terminal:})`): pure text/timing helpers unit-tested with zero subprocesses, plus three live PTY smokes against `sh` guarded by `describe.skipIf(!ptySupported())`. The harness itself is a dev instrument surface — its consumer `scripts/dx-explore.ts` never runs in CI (transcripts land in gitignored `.context/dx-runs/`); see `docs/guides/bootstrap.md` for the scenario runbook.
|
||||
- `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 +438,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.
|
||||
|
||||
|
||||
@@ -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 | | |
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
# claude-cli — the `claude-cli` recipe (routes chat/toolLoop through the local `claude` CLI)
|
||||
|
||||
This page documents the `claude-cli` recipe as it already ships (added in
|
||||
v0.42.66.0, PR #3310 — see `CHANGELOG.md`) — it is not proposing new
|
||||
behavior. The implementation lives at `src/core/ai/recipes/claude-cli.ts`
|
||||
and `src/core/ai/providers/claude-cli-language-model.ts`; neither
|
||||
`README.md` nor `docs/` mentioned this recipe before this page, so the only
|
||||
description of how it behaves lived in those source comments.
|
||||
|
||||
`claude-cli` routes `gateway.chat()` and `gateway.toolLoop()` through the
|
||||
`claude` CLI binary as a subprocess (`claude --print ...`) instead of through
|
||||
the Anthropic SDK. It sits alongside the existing `anthropic` recipe as a
|
||||
second `Recipe` entry with the same touchpoint shape; which one a given
|
||||
model string resolves to is a per-call choice: `anthropic:claude-sonnet-5`
|
||||
resolves to the `native-anthropic` implementation (SDK + `ANTHROPIC_API_KEY`),
|
||||
`claude-cli:claude-sonnet-5` resolves to `ClaudeCliLanguageModel` (subprocess,
|
||||
CLI-managed auth).
|
||||
|
||||
**Chat-only — no embedding.** `gateway.embed()` throws immediately for
|
||||
`claude-cli` models (`claude-cli has no embedding model. Use openai or google
|
||||
for embeddings.`). Claude has no first-party embedding model regardless of
|
||||
transport; pair this recipe with `openai`, `google`, or `voyage` for
|
||||
embeddings the same way the `anthropic` recipe's docs already recommend.
|
||||
|
||||
## Setup
|
||||
|
||||
1. Install Claude Code (the `claude` CLI) and run `claude` once to log in.
|
||||
If the binary is not on `PATH`, point the gateway at it explicitly:
|
||||
|
||||
```bash
|
||||
export GBRAIN_CLAUDE_CLI_BIN=/path/to/claude
|
||||
```
|
||||
|
||||
2. Point a model tier (or any per-call model string) at `claude-cli:`:
|
||||
|
||||
```bash
|
||||
gbrain config set models.tier.subagent claude-cli:claude-sonnet-5
|
||||
```
|
||||
|
||||
Any of the models the recipe declares work the same way:
|
||||
`claude-cli:claude-opus-5`, `claude-cli:claude-haiku-4-5-20251001`, etc.
|
||||
Short aliases (`claude-cli:sonnet`, `claude-cli:haiku`, `claude-cli:opus`)
|
||||
resolve the same way the `anthropic` recipe's aliases do.
|
||||
|
||||
The recipe declares `auth_env: { required: [] }`, and neither the recipe
|
||||
nor the adapter code reads or passes any API-key-shaped config value to the
|
||||
subprocess — whatever the `claude` binary does for its own auth (see below)
|
||||
is between it and its own login state, not something gbrain's config layer
|
||||
participates in. There is also no `provider_base_urls` entry for this
|
||||
recipe — it has no base URL, only a subprocess binary path
|
||||
(`GBRAIN_CLAUDE_CLI_BIN`).
|
||||
|
||||
## What actually happens on a call
|
||||
|
||||
Each `doGenerate` call spawns `claude --print --output-format json --model
|
||||
<id> --disable-slash-commands --tools '' --strict-mcp-config` as a
|
||||
subprocess, with `cwd` set to a per-process directory under the OS tmpdir
|
||||
(`join(tmpdir(), 'gbrain-claude-cli-cwd-' + process.pid)`, created via
|
||||
`mkdirSync(..., { recursive: true })` if missing — code doesn't otherwise
|
||||
touch or inspect its contents), and pipes the rendered prompt to it on
|
||||
stdin:
|
||||
|
||||
- `--tools ''` disables every built-in tool (Bash/Read/WebSearch/…) — the
|
||||
subprocess must behave like a raw LLM, not a full agent.
|
||||
- `--strict-mcp-config` skips loading the user's MCP servers. Without it,
|
||||
every call would boot the user's configured MCP servers — including
|
||||
gbrain's own MCP, which would recurse and contend for the PGLite
|
||||
single-writer lock.
|
||||
- The subprocess env is a copy of gbrain's own process env with exactly
|
||||
three keys deleted before spawn: `ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN`,
|
||||
`ANTHROPIC_BASE_URL`. Everything else in gbrain's environment is inherited
|
||||
as-is. The recipe's source comment states the intent (stop an
|
||||
`ANTHROPIC_API_KEY` present in gbrain's own env from being picked up by the
|
||||
subprocess), scoped to those three variables specifically — the doc does
|
||||
not claim this rules out every other way `claude` could end up billing
|
||||
through a non-subscription path (e.g. other env-based auth switches the CLI
|
||||
itself may support); that is between the installed `claude` binary and its
|
||||
own configuration, not something this recipe's code inspects.
|
||||
- Beyond that env-scrub, auth resolution is entirely up to the installed
|
||||
`claude` binary — the recipe does not manage or forward credentials
|
||||
itself. Whatever `claude` is already logged in / authenticated with on
|
||||
this machine is what it authenticates with here too (see the `claude` CLI's
|
||||
own docs for how it stores and resolves that).
|
||||
|
||||
`--bare` (which would skip loading the user-level `~/.claude/CLAUDE.md`
|
||||
entirely) is not among the flags passed, because it also forces
|
||||
`ANTHROPIC_API_KEY` auth (per the recipe's source comment). One effect of
|
||||
not passing it: the user-level `~/.claude/CLAUDE.md` still loads and gets
|
||||
cached tokens on every call.
|
||||
|
||||
The adapter does not use `claude`'s own agentic tool-calling — it injects a
|
||||
fenced instruction block into the system prompt teaching the model a
|
||||
`<use_tools>[{id,name,input}, ...]</use_tools>` JSON emission format
|
||||
(`buildToolUseInstructions`), then parses that block back out of the plain
|
||||
text response into ai-sdk tool-call parts (`extractToolCalls`). This
|
||||
protocol-over-text approach is what lets `supports_subagent_loop: true`
|
||||
work through the `--print`, no-built-in-tools subprocess shape described
|
||||
above.
|
||||
|
||||
## Constraints
|
||||
|
||||
| Area | Behavior |
|
||||
|---|---|
|
||||
| Embedding | Not supported — `gateway.embed()` throws for `claude-cli` models. Pair with another provider for embeddings. |
|
||||
| Streaming | Not implemented. `doStream()` throws. `gateway.toolLoop()` (the main caller) is non-streaming already, so this is not a practical limitation for subagent dispatch, but any caller that expects a streaming chat surface cannot use `claude-cli`. |
|
||||
| Tool use | JSON emission via a system-prompt-injected protocol, not the CLI's native tool-call mechanism. Parallel tool calls in one turn round-trip correctly. |
|
||||
| Multimodal | Not supported over the subprocess path. File/image message parts are rendered as a `[file <mediaType>]` text stub, not sent as actual content. |
|
||||
| Prompt caching | The recipe declares `supports_prompt_cache: false`. The CLI manages its own caching internally but does not expose it through gbrain's `cache_control` control plane, so from the gateway's point of view this model does not support prompt caching. |
|
||||
| Usage / token counts | Reported `usage.input_tokens` / `usage.output_tokens` are read straight from the CLI's `--output-format json` envelope (`result.usage?.input_tokens` / `output_tokens`); gbrain does not independently count tokens for this path. |
|
||||
| Cost figures | The recipe declares `cost_per_1m_input_usd: 3.0` / `cost_per_1m_output_usd: 15.0` — the same Sonnet-class figures the `anthropic` recipe declares (`price_last_verified: 2026-06-17`) — purely so gbrain's budget ledger has a number to attribute per call. Neither the recipe nor the adapter code checks what you're actually billed; treat these as the ledger's nominal per-call number, not a verified charge. |
|
||||
| User-level CLAUDE.md | `~/.claude/CLAUDE.md` still loads on every call (see above) — only the working directory changes (see "What actually happens on a call" for exactly what that directory is and isn't). |
|
||||
|
||||
## Known doctor caveat: cold-start subprocess vs the fixed 5s probe timeout
|
||||
|
||||
`gbrain models doctor`'s chat reachability probe (`probeModel` in
|
||||
`src/commands/models.ts`) wraps every chat call in a fixed 5-second
|
||||
`AbortController` timeout, independent of any per-recipe timeout the recipe
|
||||
itself declares (`claude-cli` does not declare a `default_timeout_ms`).
|
||||
Spawning the `claude` binary and letting it start up is generally fast, but
|
||||
is not instantaneous — a slow first invocation (cold process cache, slow
|
||||
disk, contended machine) can outrun that 5-second window.
|
||||
|
||||
When that happens, the probe's `AbortController` fires, the subprocess is
|
||||
killed (`child.kill('SIGTERM')`), and the adapter's abort handler rejects
|
||||
with a fixed message (`claude-cli adapter aborted`). `classifyError` in
|
||||
`src/commands/models.ts` only maps a message to `status: network` if it
|
||||
matches `/timeout|network|econn|fetch failed|enotfound/`; `claude-cli
|
||||
adapter aborted` matches none of those, so it falls through to
|
||||
`status: unknown` — the classifier's catch-all — instead of `status:
|
||||
network`, which is what a plain slow/unreachable HTTP provider would map
|
||||
to on the same probe timeout. So a `status: unknown` result on a
|
||||
`claude-cli:` model is not necessarily a broken configuration on its own;
|
||||
a cold subprocess start outrunning the fixed 5s window is one thing that
|
||||
can produce it (the same class of first-call cold-start the embedding
|
||||
reachability probe's own code comment already calls out for local
|
||||
embedders), and re-running the probe is a reasonable first thing to try.
|
||||
`status: unknown` on its own doesn't distinguish that from any other
|
||||
unclassified failure, so if a re-run keeps producing it, treat it as an
|
||||
unclassified error worth investigating rather than assuming cold-start.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Where it comes from | Try |
|
||||
|---|---|---|
|
||||
| `claude-cli spawn failed: ...` / stdin write failure | `spawn()`'s `error` event or a failed `stdin.write` — commonly means the `claude` binary was not found on `PATH` | Install Claude Code, or set `GBRAIN_CLAUDE_CLI_BIN` to the binary's path |
|
||||
| `claude-cli exited <code>: ...` | Non-zero exit from the `claude` subprocess itself; the message is whatever the CLI wrote to stderr/stdout | Run `claude` interactively with the same model to see the underlying CLI error directly (e.g. not logged in, model unavailable) |
|
||||
| `claude-cli output not JSON: ...` | `JSON.parse(stdout)` threw (stdout wasn't valid JSON at all) | Confirm the installed `claude` CLI version still supports `--print --output-format json`; this adapter's JSON handling was verified against CLI 2.1.145 |
|
||||
| `claude-cli JSON event array had no "result" event` | stdout parsed as a JSON array (the `"verbose": true` event-stream shape in `~/.claude/settings.json`) but none of the events had `type: "result"` | Check `~/.claude/settings.json` for `"verbose": true`; the adapter tolerates the array shape but still needs a `result` event in it |
|
||||
| `gbrain models doctor` reports `chat` as `status: unknown` for a `claude-cli:` model | See "Known doctor caveat" above — `classifyError` falls through to `unknown` for the adapter's abort message | Re-run the probe; if it persists, treat it as an unclassified failure and investigate directly (e.g. run the same model via `gbrain models doctor --json` or call `claude` by hand) |
|
||||
| A call bills through the Anthropic API instead of the local session | The adapter deletes `ANTHROPIC_API_KEY` / `ANTHROPIC_AUTH_TOKEN` / `ANTHROPIC_BASE_URL` from the subprocess env — this covers gbrain's own env leaking into the call. It does not inspect any other auth/billing switch the installed `claude` CLI itself may support | If billing looks wrong, check the `claude` CLI's own auth/billing configuration on this machine, not just gbrain's env |
|
||||
@@ -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
@@ -86,7 +86,7 @@ the repo. The architectural rule still holds — these aren't
|
||||
| `mcp_request_log` | Audit trail. Volatile by design. |
|
||||
| `minion_jobs` / `minion_inbox` / `minion_attachments` | Job queue. Restarts re-enqueue or drop. |
|
||||
| `eval_candidates` / `eval_capture_failures` | Contributor-mode dev loop; opt-in capture. |
|
||||
| `dream_verdicts` | Cheap verdict cache. Rebuildable by re-running Haiku. |
|
||||
| `dream_verdicts` | Scored triage cache (salience score, quotes, entities, judging model + prompt version). Rebuildable via `gbrain dream retriage --force`. |
|
||||
| `gbrain_cycle_locks` / migration ledger | Infrastructure. |
|
||||
| `op_checkpoint_paths` | Sync-resume checkpoint. Append-only progress banking; a completed sync makes it irrelevant. |
|
||||
| `config` (some keys) | Site-local routing config (e.g. `sync.repo_path`). |
|
||||
|
||||
@@ -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):
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# Fix-wave series baselines (W0 → W9)
|
||||
|
||||
Recorded per wave so the series' "10x better for 2x effort" claim is measured,
|
||||
not vibed (fix-wave plan D4.13). Update this file in each wave's PR; keep the
|
||||
prior rows — the deltas ARE the receipt.
|
||||
|
||||
## How to refresh
|
||||
|
||||
```bash
|
||||
wc -l src/commands/doctor.ts src/core/pglite-engine.ts src/core/postgres-engine.ts \
|
||||
src/core/operations.ts src/core/migrate.ts src/commands/sync.ts \
|
||||
src/core/ai/gateway.ts src/cli.ts src/core/engine.ts \
|
||||
src/core/search/hybrid.ts src/core/search/mode.ts src/core/cycle.ts
|
||||
ls scripts/check-* | wc -l # guard count
|
||||
bash scripts/guard-self-test.sh # self-tested count + harness runtime
|
||||
bun run test > /tmp/suite.txt 2>&1; echo $? # wall-clock from the run banner
|
||||
```
|
||||
|
||||
Retrieval-quality canary (MANDATORY before W1, and after W1/W3/W9): run
|
||||
`gbrain eval gate` against a NON-PRODUCTION brain (the production PGLite brain
|
||||
is single-writer and usually held by a live `gbrain serve`; eval runs never
|
||||
touch `~/.gbrain` per the eval discipline — results land in
|
||||
`<repo>/.gbrain-evals/eval-results.jsonl`). Record the gate verdict + headline
|
||||
metrics here per run.
|
||||
|
||||
## W0 (2026-08-14, branch garrytan/code-smell-fix-wave @ post-hotfix)
|
||||
|
||||
God-file line counts (the audit's structural targets, BEFORE the registry waves):
|
||||
|
||||
| File | Lines |
|
||||
|---|---|
|
||||
| src/commands/doctor.ts | 10,057 |
|
||||
| src/core/operations.ts | 7,459 |
|
||||
| src/core/pglite-engine.ts | 6,874 |
|
||||
| src/core/postgres-engine.ts | 6,847 |
|
||||
| src/core/migrate.ts | 6,201 |
|
||||
| src/commands/sync.ts | 5,991 |
|
||||
| src/core/ai/gateway.ts | 4,049 |
|
||||
| src/cli.ts | 3,301 |
|
||||
| src/core/cycle.ts | 2,933 |
|
||||
| src/core/search/hybrid.ts | 2,453 |
|
||||
| src/core/engine.ts | 2,320 |
|
||||
| src/core/search/mode.ts | 1,232 |
|
||||
|
||||
Guards: 47 scripts/check-* files; 3 self-tested (harness <1s, budget 30s);
|
||||
single registry established (guards-manifest.tsv; `check:all` deleted; 3
|
||||
previously-unreachable guards wired into verify).
|
||||
|
||||
Test infra: PGLite snapshot default-on for `bun run test`. Per-PGLite-file:
|
||||
1.63s cold → 0.91s snapshotted (measured on test/db-lock-fencing.test.ts).
|
||||
Full-suite wall-clock (post-snapshot): recorded in the W0 ship notes — see
|
||||
the run banner of the W0 PR's `bun run test` evidence.
|
||||
|
||||
Retrieval canary: NOT RUN at W0 (production brain locked by live serve; W0
|
||||
touches no search paths). REQUIRED before W1 lands.
|
||||
|
||||
Verified-bug status at W0 ship: cycle-lock refresh + fencing (TODO-OPS-2
|
||||
closed), stall-death parent unblock, started_at ×4, modality carry, import
|
||||
typed aborts, lint single-pass, prompt EOF safety, guard self-test harness,
|
||||
snapshot default-on. W0a superseded by master's WP1/D7 (port-ledger in the
|
||||
plan file).
|
||||
@@ -32,7 +32,7 @@ pure win. See the per-verb latency table in
|
||||
calls `context_pack` / `delta` over MCP (they are on `--surface verbs`) or the
|
||||
CLI (`gbrain context-pack`, `gbrain delta`) at the boundary and injects the
|
||||
returned `text` (or renders the structured arms). This is the portable path —
|
||||
no hooks required. It is the primary path for Codex (which has no hooks) and
|
||||
no hooks required. It is the primary path for Codex and opencode (no wired hooks) and
|
||||
for Postgres brains (which have no local IPC socket).
|
||||
- **Push (PGLite + Claude Code):** the bundled hook framework fires
|
||||
automatically at `SessionStart` (injects a warm pack — including the
|
||||
|
||||
+134
-8
@@ -1,7 +1,7 @@
|
||||
# GBrain Bootstrap — your harness as your agent
|
||||
|
||||
`gbrain bootstrap` turns a Claude Code or Codex session into a persistent personal
|
||||
agent: identity files rendered from your own answers, a local PGLite brain,
|
||||
`gbrain bootstrap` turns a Claude Code, Codex, or opencode session into a
|
||||
persistent personal agent: identity files rendered from your own answers, a local PGLite brain,
|
||||
per-turn context, session-triggered schedules, and a private GitHub repo as the
|
||||
agent's durable, portable body. This guide is the full contract — what gets
|
||||
installed, what runs when, what it can and cannot do, and how to undo all of it.
|
||||
@@ -19,7 +19,7 @@ follows is `BOOTSTRAP_FOR_AGENTS.md` at the repo root, fetched at the
|
||||
| Identity files (SOUL/USER/MEMORY/AGENTS/CLAUDE/HEARTBEAT/ACCESS_POLICY/GITHUB) | your workspace folder | loaded at session start |
|
||||
| `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 |
|
||||
| MCP registration (`gbrain serve`) | Claude Code: project scope by default; Codex: user-global (no scope flag); opencode: user-global by default (project scope is an explicit opt-in — see the degradation matrix) | spawned by your harness per session |
|
||||
| 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) |
|
||||
@@ -100,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)).
|
||||
|
||||
@@ -152,8 +154,92 @@ 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) |
|
||||
| opencode (no wired hooks; scope INVERTED: user-global by default) | pull protocol (opencode reads AGENTS.md natively) + MCP tools; project scope available as an explicit opt-in | per-turn push (opencode ships a plugin/event system, but gbrain does not wire it yet). The project-scope default is deliberately NOT offered: opencode spawns project-config servers with no trust prompt, so a committed entry would auto-execute on every collaborator machine |
|
||||
| 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.
|
||||
- opencode: one managed `mcp.gbrain` remote entry with the bearer header
|
||||
INLINE in the user-global JSONC config (0600), written by the same
|
||||
comment-preserving editor the workspace lane uses — the `{env:…}`
|
||||
interpolation the `connect` path prefers would resolve empty under a
|
||||
framework-spawned opencode for the same no-shell-profile reason.
|
||||
Note: downgrading gbrain below the release that introduced opencode support
|
||||
after wiring it leaves the opencode entry in place for manual removal —
|
||||
edit the opencode config by hand, or re-upgrade and run
|
||||
`gbrain bootstrap harness --remove`.
|
||||
- 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
|
||||
|
||||
@@ -193,11 +279,16 @@ 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).
|
||||
|
||||
opencode's real-binary door lives in
|
||||
`test/e2e/install-real-opencode.serial.test.ts` (its writer-parity leg
|
||||
handshakes gbrain's direct JSONC registration through the actual binary);
|
||||
`docs/TESTING.md` carries the full door inventory and cadence policy.
|
||||
|
||||
These pay real API cost and take 30s–2min per turn, so they are NOT in the PR
|
||||
shard. Everything is hermetic (temp `HOME` / `CODEX_HOME` / `CLAUDE_CONFIG_DIR` /
|
||||
`GBRAIN_HOME` per test — the operator's real `~/.claude`, `~/.gbrain`, `~/.codex`
|
||||
@@ -214,3 +305,38 @@ 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`, `grok`, `opencode`) 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).
|
||||
The harness itself also backs one required-CI test: `test/init-picker-pty.serial.test.ts`
|
||||
asserts the interactive `gbrain init` pickers under a real PTY (see the
|
||||
TTY decision table in `docs/TESTING.md`). The DX-exploration layer below stays
|
||||
an instrument — nothing in it asserts.
|
||||
|
||||
`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 opencode-install # REAL opencode running the paste-in bootstrap
|
||||
bun run scripts/dx-explore.ts grok-install # REAL grok, brain-only GROK.md install (no bootstrap path)
|
||||
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.
|
||||
|
||||
@@ -115,6 +115,49 @@ it nightly and Phase 4 below (plus most of Phase 2's hygiene checks) is
|
||||
covered. The pseudocode that follows is the harness-side variant for agents
|
||||
that also do LLM-driven entity sweeps and memory consolidation on top.
|
||||
|
||||
### Synthesis cost control: the triage cascade
|
||||
|
||||
The synthesize phase is a two-stage cascade: a cheap scored triage
|
||||
(utility-tier model, one call per new transcript) gates the expensive
|
||||
per-transcript synthesis subagents. The dials:
|
||||
|
||||
- `dream.triage.threshold` (default 0.5) — the gate. Scores are cached, so
|
||||
retuning it re-gates instantly with **zero** new LLM calls. Raise it if too
|
||||
much routine content synthesizes; lower it if real signal is being skipped.
|
||||
- `models.dream.triage` — the triage model (default: utility tier / Haiku).
|
||||
- `dream.triage.max_chars` (default 24000, floor 1000) — per-transcript
|
||||
sample window (head/middle/tail) sent to the judge. Not part of cache
|
||||
validity — after changing it, `gbrain dream retriage --force` re-judges
|
||||
under the new sampling.
|
||||
- `dream.triage.max_tokens` (default 2048, floor 256) — judge output budget.
|
||||
- `dream.triage.concurrency` (default 4, clamped 1–16) — concurrent judge
|
||||
calls.
|
||||
- `dream.synthesize.max_turns` (default 16) — synthesis turn budget. The
|
||||
triage map hands the subagent pre-extracted segments, so the mid-tier
|
||||
default model (`models.dream.synthesize`, tier `reasoning`) with a 16-turn
|
||||
budget is the intended pairing — frontier-model overrides are unnecessary
|
||||
and slow the queue. Completeness comes from triage coverage (every file
|
||||
scored, minus files deferred under the `max_ms` budget below) plus
|
||||
segment-guided prompts, not model size. If written-page counts
|
||||
drop after upgrading, set it back to 30 and check
|
||||
`details.synthesis.avg_turns` for cap pressure.
|
||||
- `dream.triage.max_ms` (default 5 min) — per-cycle wall-clock budget for
|
||||
judging NEW files; a big cold corpus triages across a few cycles (cached
|
||||
files are free). Deferred files are labeled "not yet triaged", never
|
||||
silently rejected.
|
||||
- `dream.synthesize.max_submissions_per_source_per_day` (default 0 = off) —
|
||||
opt-in backstop cap on synthesis jobs per source; 200/day is a sane value
|
||||
for busy deployments.
|
||||
|
||||
Maintenance recipe — after changing the threshold, upgrading through a
|
||||
`TRIAGE_VERSION` bump, or to drain a queued synthesis backlog:
|
||||
|
||||
```bash
|
||||
gbrain dream retriage --dry-run # what would change (zero LLM calls)
|
||||
gbrain dream retriage --reconcile-queue # re-score + cancel below-threshold queued jobs
|
||||
gbrain dream retriage --audit-rejects 20 # synthesis-model second opinion on 20 rejects
|
||||
```
|
||||
|
||||
### What It Does
|
||||
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -87,6 +87,69 @@ check warns if what you asked for isn't what's actually running (e.g. a
|
||||
negative value denied without privilege, or an OS `RLIMIT_NICE` clamp). This
|
||||
is distinct from the concurrency / inflight cap and composes with it.
|
||||
|
||||
### Per-job process isolation (`--job-isolation process`)
|
||||
|
||||
By default all concurrency slots execute inside one worker process. A
|
||||
handler that ignores its abort signal can only be force-evicted — the
|
||||
promise is abandoned, still running, still holding connections and memory —
|
||||
and any worker exit destroys every in-flight job at once. With isolation on,
|
||||
each claimed job runs in its own child process: a stuck handler is
|
||||
group-SIGKILLed for real (group signaling under Bun falls back to POSIX
|
||||
`/bin/kill`; if that's unavailable the worker logs that isolation is
|
||||
degraded), a crash or OOM in a child takes that one job instead of all N,
|
||||
and the OS reclaims every leaked resource when the child dies:
|
||||
|
||||
```bash
|
||||
# Recommended for long-running LLM-bound handlers (subagent):
|
||||
gbrain jobs supervisor --concurrency 4 --job-isolation process
|
||||
|
||||
# Bare worker, or durably via env:
|
||||
GBRAIN_JOB_ISOLATION=process gbrain jobs work --concurrency 4
|
||||
```
|
||||
|
||||
How it works: the worker keeps claim, lock renewal, and all result
|
||||
recording; the child (an internal `run-child` entrypoint of the same gbrain
|
||||
binary) re-validates the claim, runs the handler with its own small engine
|
||||
pool, and reports one atomic outcome file. Handler-error semantics are
|
||||
preserved across the boundary (unrecoverable → dead, rate-lease → no attempt
|
||||
burned, everything else → normal backoff). On worker shutdown children get
|
||||
the drain window to finish and report; a child killed before reporting is
|
||||
released with no attempt burned. If the worker dies hard, the orphaned child
|
||||
self-terminates via a parent-liveness watchdog and the stall sweeper
|
||||
requeues the job after lock expiry — the lock token fences the orphan's
|
||||
queue writes (result recording, progress, state transitions) into no-ops.
|
||||
The handler's own side effects (page writes through its engine) can still
|
||||
land until the watchdog stops the child; that window is the watchdog's
|
||||
poll + grace, not unbounded.
|
||||
|
||||
Sizing notes:
|
||||
|
||||
- **Connections:** each child opens its own small pools (read 3 by default,
|
||||
override via `GBRAIN_JOB_CHILD_POOL_SIZE`; direct 1). Worked example at
|
||||
concurrency 15: 15×(3+1) + the worker's 10+3 ≈ **73 client connections**
|
||||
total — 55 ride the transaction-pooler lane (multiplexed, no extra server
|
||||
backends) and 18 are lazy direct session-lane connections, each holding a
|
||||
real server backend while open. Budget the pooler-lane count against your
|
||||
pooler's client limit and the session-lane count against
|
||||
`max_connections`.
|
||||
- **Memory:** `--max-rss` covers the WORKER process only in this mode
|
||||
(handler memory lives in the children; the worker prints a note when both
|
||||
are set). There is no per-child RSS cap yet — a runaway child is contained
|
||||
only by host/container limits. Size host memory for concurrency × handler
|
||||
footprint.
|
||||
- **Spawn cost:** ~0.3–1s per job (engine connect included) — noise for
|
||||
long-running handlers, meaningful for sub-second ones (`lint`,
|
||||
`backlinks`). Keep those inline or on a separate inline worker.
|
||||
- **Security note:** the child receives the job's lock token via env. It is
|
||||
a *fencing* token (split-brain protection), not a secret — same-user env
|
||||
already contains the database URL.
|
||||
- **Child CLI resolution:** the worker fail-fast validates the child CLI at
|
||||
startup (compiled `gbrain` binary, bun-dev fallback, or the
|
||||
`GBRAIN_JOB_CHILD_CLI` env override — the ops/test escape hatch). Three
|
||||
consecutive child spawn/bootstrap failures self-exit the worker as
|
||||
unhealthy (a deterministically broken child CLI) for process-manager
|
||||
restart instead of burning attempts across the queue.
|
||||
|
||||
### Which supervisor when?
|
||||
|
||||
The supervisor solves in-process crash recovery. Platform-level
|
||||
@@ -140,7 +203,7 @@ Three-command pattern an agent can drive without shell archaeology:
|
||||
```bash
|
||||
# Start (returns PIDs + pid_file on stdout as JSON, then detaches)
|
||||
gbrain jobs supervisor start --detach --json
|
||||
# → {"event":"started","supervisor_pid":1234,"worker_pid":1235,"pid_file":"/Users/you/.gbrain/supervisor.pid"}
|
||||
# → {"event":"started","supervisor_pid":1234,"pid_file":"/Users/you/.gbrain/supervisor-<brain-id>.pid","detached":true}
|
||||
|
||||
# Check health (machine-parseable JSON, no log scraping)
|
||||
gbrain jobs supervisor status --json
|
||||
|
||||
@@ -12,7 +12,7 @@ The push channels share one zero-LLM core (`src/core/context/volunteer.ts`):
|
||||
| `reflex` | automatic, inside the context engine | default-on for plugin hosts; nothing to call |
|
||||
| `op` | `gbrain volunteer-context` / MCP `volunteer_context` | agents without the plugin; one call per turn |
|
||||
| `watch` | `gbrain watch` | stream a transcript in, volunteered pages stream out |
|
||||
| `claude-code` / `codex` | `gbrain hook user-prompt` (registered by `gbrain bootstrap`) | per-prompt injection inside a harness; see "Harness hooks" below |
|
||||
| `claude-code` / `codex` / `opencode` | `gbrain hook user-prompt` (registered by `gbrain bootstrap`) | per-prompt injection inside a harness; see "Harness hooks" below |
|
||||
|
||||
## How it decides
|
||||
|
||||
@@ -74,7 +74,7 @@ this channel production-grade rather than spammy-and-invisible:
|
||||
- **The feedback loop.** The serve logs each DELIVERED block's volunteered
|
||||
pages and pointers to `context_volunteer_events` under the hook's channel
|
||||
(`claude-code` by default; a codex hook registration passes
|
||||
`--harness codex`). `gbrain volunteer-context --stats` then shows
|
||||
`--harness codex` / `--harness opencode`). `gbrain volunteer-context --stats` then shows
|
||||
per-harness precision, and `gbrain doctor`'s `volunteer_channels` check
|
||||
shows which channels actually fire, with guidance for the two quiet cases:
|
||||
"hook installed but never registered (restart the session)" and "registered
|
||||
|
||||
@@ -71,7 +71,10 @@ gbrain jobs get <id>
|
||||
## Rescue actions (in order of escalation)
|
||||
|
||||
```bash
|
||||
# Force-kill a single stuck job:
|
||||
# Cancel a single stuck job (inline mode: cooperative — the handler must
|
||||
# observe its abort signal, and after 30s it is force-evicted from tracking
|
||||
# but the promise keeps running; with --job-isolation process the child is
|
||||
# actually SIGTERM→SIGKILLed once cancellation is detected):
|
||||
gbrain jobs cancel <id>
|
||||
|
||||
# Clear a specific job entirely (last resort):
|
||||
@@ -85,8 +88,20 @@ gbrain jobs smoke --wedge-rescue
|
||||
|
||||
- **stalled-forever** — A worker claimed a job, started executing, and has
|
||||
held the row for over an hour. The wall-clock sweep evicts jobs past
|
||||
2× `timeout_ms`; if one's still active, either no `timeout_ms` was set
|
||||
or the sweep is newly deployed and this job predates it. Cancel it.
|
||||
2× `timeout_ms`. Long-lane handlers (subagent, autopilot-cycle,
|
||||
embed-backfill, …) always have a budget now: it stamps at submit, is
|
||||
COALESCEd from `HANDLER_DEFAULT_TIMEOUT_MS` at claim for legacy NULL rows,
|
||||
and migration v128 backfilled rows that predate both. `gbrain jobs get <id>`
|
||||
prints the effective budget and which kill path applies. If a short-lane
|
||||
job is still active with no budget, the null-default sweep
|
||||
(2 × lock-duration × max_stalled) evicts it within minutes. Cancel it if
|
||||
you can't wait.
|
||||
- **duplicate cycles** — Historic brains could accumulate byte-identical
|
||||
waiting `autopilot-cycle` rows when a job stalled in `active`. v128
|
||||
cancelled that backlog (newest ticker-keyed row per source survives), and
|
||||
the `maxPending` dispatch guard prevents new accumulation. Suppressed
|
||||
dispatches are visible in `jobs stats` (Backpressure line) and the
|
||||
backpressure audit JSONL.
|
||||
- **waiting-depth** — Submitters are piling up jobs faster than workers
|
||||
drain them. Set `--max-waiting N` on the submission or on the programmatic
|
||||
`queue.add()` call. If you want a taller pile, raise the threshold via
|
||||
@@ -107,6 +122,29 @@ claiming. Start one:
|
||||
GBRAIN_ALLOW_SHELL_JOBS=1 gbrain jobs work --concurrency 4
|
||||
```
|
||||
|
||||
## Reading the DB-probe verdicts (pool starved vs server unreachable)
|
||||
|
||||
When the worker's health probe fails repeatedly, the terminal
|
||||
`[health] DB probe failed N consecutive times (verdict: ...)` line — and the
|
||||
`unhealthy` payload the supervisor sees — carries a verdict that names the
|
||||
failing LAYER (the intermediate `(N/3)` lines log only the failure detail).
|
||||
Read it before touching anything — the historical failure mode here was
|
||||
hours spent evaluating a database instance upgrade while the server sat at
|
||||
10% of max_connections.
|
||||
|
||||
| Verdict | What it means | What to do |
|
||||
|---|---|---|
|
||||
| `pool_starved` | The read-pool probe failed but the DIRECT-lane probe succeeded — the database server is reachable; the fault is in the transaction-pooler path (client pool exhaustion or a pooler-layer fault; the probe deliberately does not distinguish the two). | Look at client-side load: long-running handler queries holding slots, `GBRAIN_POOL_SIZE` too small for the workload, or a pooler-layer incident. Do NOT resize the database. The worker exit is correct recovery — it frees every client-held slot. |
|
||||
| `server_unreachable` | Both the pooler lane and the direct lane failed. | Check connectivity/capacity first: network, DNS, the database itself. Both-lanes-failed is the evidence — credential/config errors or a saturated direct lane can also land here, so glance at the probe detail text before concluding the server is down. |
|
||||
| `unknown` | The read probe failed and no direct lane exists to disambiguate (single-pool mode: non-Supabase, kill switch active, or no derivable direct URL). | Check the startup log for the single-pool warning; consider `GBRAIN_DIRECT_DATABASE_URL` so future incidents self-diagnose. |
|
||||
|
||||
The `gbrain-tracked in flight` counts in the message are a tracked SUBSET
|
||||
(raw/direct/reserved/transaction seams only) — most template-path queries are
|
||||
untracked, so `0 in flight` next to a `pool_starved` verdict means the
|
||||
saturation lives in that untracked traffic or at the pooler layer itself,
|
||||
not that the pool is idle. The verdict, not the counts, is the
|
||||
authoritative signal.
|
||||
|
||||
## Related
|
||||
|
||||
- [Minions worker deployment](minions-deployment.md) — supervisor lifecycle,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
# Grok Build CLI pin — observed behavior notes (v1.0.4)
|
||||
|
||||
Dev-facing companion to [GROK.md](GROK.md): every fact below was OBSERVED against a
|
||||
real install (2026-08-14), not researched from docs. The claw-test GrokRunner, the
|
||||
install door e2e, and the heavy-tests grok-door CI job assert exactly these shapes —
|
||||
when Grok Build releases change them, update this file, the workflow pins, and the
|
||||
affected assertions together (`scripts/check-grok-pin.sh` in `bun run verify` enforces
|
||||
the workflow-side match).
|
||||
|
||||
Naming note: **grok** (xAI Grok Build CLI, `XAI_API_KEY`) is not **groq** (Groq Inc.
|
||||
inference, `src/core/ai/recipes/groq.ts`, `GROQ_API_KEY`) and not **ngrok** (tunnels).
|
||||
|
||||
<!-- grok-pin: distribution_kind=npm -->
|
||||
<!-- grok-pin: npm_package=@xai-official/grok -->
|
||||
<!-- grok-pin: npm_version=1.0.4 -->
|
||||
<!-- grok-pin: npm_integrity=sha512-Nu3SFXTqwvCQr/LQFwrQYgngJhUQwX2h9ZSgzW4HowidjbPBWtMVO0xI88d2z6/zlDSNaT5YP/uk+2DthKQMsg== -->
|
||||
<!-- grok-pin: npm_linux_x64_integrity=sha512-Dan2LfKcFBiabuDGHaGgMT8Ndzibo2ljvSjh4MlpV5117JL+S/0KMbdyYpk+13d7t+4znniW1cm+rRwUGSAvtw== -->
|
||||
<!-- grok-pin: npm_linux_arm64_integrity=sha512-zGK42Eq3ZmIa7cSVnl6CiJ4cxTCMsNLQCmCoLJhy5eZXfAvZ1DA3K3HXmKCj4OScX8SalYlp7mx8HWl9Y6gytw== -->
|
||||
<!-- grok-pin: grok_version=1.0.4 -->
|
||||
<!-- grok-pin: installer_sha256=43d0943123edade1383a476a4f778674877acee7c1f98a00f094c4a0f7349321 -->
|
||||
<!-- grok-pin: observed_date=2026-08-14 -->
|
||||
|
||||
## Pin
|
||||
- **Grok Build v1.0.4**, `grok --version` output shape: `grok 1.0.4 (d846eb93d94d)`
|
||||
(version + build hash; the door's shape assert is `/^grok \d+\.\d+\.\d+ \([0-9a-f]+\)$/`).
|
||||
- **Provisioning (CI + local): pinned npm install** — `@xai-official/grok@1.0.4`,
|
||||
registry integrity `sha512-Nu3SFX…`. The package fans out to
|
||||
`@xai-official/grok-{darwin,linux,win32}-{arm64,x64}` optional deps at the same
|
||||
version; the CI job pins the LINUX payload integrities too (stamps above) because
|
||||
the wrapper's integrity covers only the wrapper tarball — the platform sub-package
|
||||
is the binary that executes. Load-bearing assumption, stated explicitly: npm
|
||||
version-immutability (a published version cannot be replaced on npmjs; only a new
|
||||
version or an unpublish, both of which fail the pinned install loudly).
|
||||
- Installer path (fallback only): `https://x.ai/cli/install.sh`, sha256
|
||||
`43d0943123edade1383a476a4f778674877acee7c1f98a00f094c4a0f7349321` (17,686 bytes).
|
||||
It SUPPORTS version pinning (`bash -s <X.Y.Z>`) and downloads versioned artifacts
|
||||
`grok-<version>-<os>-<arch>` from `https://x.ai/cli` (fallback GCS bucket
|
||||
`grok-build-public-artifacts`), self-checks `--version` post-download. Platform
|
||||
string from `uname -s`/`uname -m` with a Rosetta correction on Apple Silicon.
|
||||
- Verified against macOS arm64; npm `os`/`cpu` matrix covers linux x64/arm64 for CI.
|
||||
|
||||
## GROK_HOME — HONORED (verified)
|
||||
`GROK_HOME=<tmp> HOME=<tmp> grok mcp list|add|doctor` read+write `<tmp>/config.toml`
|
||||
and do NOT touch `~/.grok`. Belt-and-suspenders (HOME + GROK_HOME both to tmp) stays
|
||||
in the door anyway. NOTE what grok writes into `$GROK_HOME` on EVERY run (tripwire
|
||||
exclusions — these are VOLATILE): `active_sessions.lock`, `active_sessions.json`,
|
||||
`bin/grok-<version>` (it copies its own binary in), `logs/unified.jsonl`,
|
||||
`docs/user-guide/*.md` (it ships its user guide into the home), `leader.sock` (a
|
||||
leader daemon socket; `--leader-socket <PATH>` overrides). The tripwire hashes ONLY
|
||||
`config.toml` + credential-class files, never the volatile set.
|
||||
|
||||
## One-shot (`-p`)
|
||||
- `grok -p "<prompt>"` (`-p, --single`) prints the response to stdout and exits.
|
||||
- `--output-format plain|json|streaming-json|streaming-messages-json` (default plain;
|
||||
`streaming-json` = NDJSON of native ACP session updates; `streaming-messages-json` =
|
||||
Anthropic Messages wire format; `--include-partial-messages` adds deltas).
|
||||
- **Keyless one-shot: exit 1**, message (verbatim, both stdout and stderr):
|
||||
`Not signed in. To authenticate without a browser, run:\n grok login --device-code\n\nAlternatively, set the XAI_API_KEY environment variable or run `grok login` on a machine with a browser.`
|
||||
→ `hasGrokAuth()` = non-empty `XAI_API_KEY`; the TTY scenario's keyless early-stop
|
||||
matcher is `Not signed in`.
|
||||
- Cost/toolset flags that EXIST (observed in --help): `--always-approve`,
|
||||
`--permission-mode default|acceptEdits|auto|dontAsk|bypassPermissions|plan`,
|
||||
`--tools <LIST>`, `--disallowed-tools <LIST>`, `--allow/--deny <RULE>`,
|
||||
`--disable-web-search` (dedicated kill for web search + fetch — the door SMOKE uses
|
||||
THIS, not a tools list), `--max-turns <N>`, `-m/--model`, `--reasoning-effort`
|
||||
(alias `--effort`), `--cwd <PATH>`, `--rules`, `--prompt-file`, `--prompt-json`,
|
||||
`--json-schema` (implies json output), `--verbatim`, `--sandbox <PROFILE>`
|
||||
(env `GROK_SANDBOX`), `--no-memory`, `--no-plan`, `--no-subagents`.
|
||||
- There is NO auto-update CLI flag. Auto-update is config: `[cli] auto_update = true`
|
||||
is the DEFAULT — hermetic homes MUST seed `[cli] auto_update = false`. Manual
|
||||
updater: `grok update [--check --json --version <V> --force-reinstall --alpha]`.
|
||||
- `.envrc` gotcha: `load_envrc = true` by default — grok loads `.envrc` from the
|
||||
working directory. Door/live spawns pin `cwd` to tmp workspaces partly for this.
|
||||
|
||||
## Auth + model pin (non-interactive)
|
||||
- Keyless error pinned above; `grok login --device-code` exists for headless
|
||||
interactive auth; `XAI_API_KEY` env is the documented headless path (its end-to-end
|
||||
smoke is **pending auth** — no key was available at observation time; the door's
|
||||
paid tier stays skip-gated until then, per plan D0).
|
||||
- `grok models` works KEYLESS (exit 0): prints `You are not authenticated.`, then
|
||||
`Default model: grok-4.6` and the visible list (`grok-4.6 (default)`, `grok-4.5`).
|
||||
Authenticated list may be larger; per-turn cost pins are **pending auth**.
|
||||
- Model pin mechanism: per-call `-m <model>` (authoritative in tests — immune to
|
||||
config rewrites) and `[models] default = "<model>"` in config.toml.
|
||||
|
||||
## `grok mcp add` — THE big observed facts
|
||||
- Shape: `grok mcp add <name> [-e KEY=value]... [-s user|project] [-t stdio|http|sse] -- <command> [args...]`
|
||||
— everything after `--` is the server argv. **`-e/--env` is REPEATABLE, one
|
||||
KEY=value per flag** (their docs pin this as a breaking change from earlier
|
||||
releases: `use -e A=1 -e B=2, not --env A=1 B=2` — the hermes replace-bug class is
|
||||
fixed upstream). Server names: letters, numbers, hyphens, underscores only.
|
||||
- **Add is LAZY: exit 0 always, NO handshake at add time, no interactive prompt**
|
||||
(`Added stdio MCP server 'gbrain' … to user config` / `File modified:
|
||||
$GROK_HOME/config.toml`). Adding a NONEXISTENT command also exits 0. Never assert
|
||||
add's exit code; never treat `enabled = true` in the saved TOML as a handshake
|
||||
proof (it is written unconditionally).
|
||||
- Scope: `-s user` (default) → `~/.grok/config.toml`; `-s project` →
|
||||
`./.grok/config.toml` (committable; reference secrets as `${VAR}`).
|
||||
- **Bare command names resolve via the CALLER'S PATH** (verified): registering
|
||||
`-- gbrain serve --surface verbs` with a PATH-prefixed bin dir works — doctor
|
||||
resolved bare `gbrain` to the staged wrapper and completed the handshake. The
|
||||
bun-run wrapper shim (`#!/bin/sh\nexec bun run <abs>/src/cli.ts "$@"`) works as the
|
||||
staged binary (the fallback lane when a compiled binary is unavailable).
|
||||
- Startup timeout: per-server `startup_timeout_sec` (default 30) or global env
|
||||
`GROK_MCP_STARTUP_TIMEOUT_SECS` (seconds) / `MCP_TIMEOUT` (ms, Claude-compatible).
|
||||
The bun-run wrapper cold-transpiles slowly — the door sets 60+.
|
||||
|
||||
## Saved config schema (verbatim, from a real add)
|
||||
```toml
|
||||
[mcp_servers.gbrain]
|
||||
command = "/tmp/<staged-bin>/gbrain"
|
||||
args = [
|
||||
"serve",
|
||||
"--surface",
|
||||
"verbs",
|
||||
]
|
||||
enabled = true
|
||||
|
||||
[mcp_servers.gbrain.env]
|
||||
GBRAIN_SOURCE = "workspace"
|
||||
GBRAIN_HOME = "/tmp/<brain-home>"
|
||||
```
|
||||
Full schema keys (from grok's own shipped user guide, `$GROK_HOME/docs/user-guide/`):
|
||||
`command`, `args`, `env`, `enabled` (default true), `startup_timeout_sec` (default
|
||||
30), `tool_timeout_sec` (default 6000), `tool_timeouts`.
|
||||
|
||||
## Probes — the HONEST discriminator exists
|
||||
- **`grok mcp doctor <name> --json`**: SPAWNS the server for real. Good server →
|
||||
**exit 0** with checks `command found` / `server started` / `handshake OK`
|
||||
(`"detail": "protocol 2025-11-25"`) / **`7 tools discovered`** (the verbs surface's
|
||||
seven verbs, proven keyless end-to-end). Broken server (nonexistent command) →
|
||||
**exit 1**, check `command not found`, `passed: false`, plus a `hint`. THE door's
|
||||
hard discriminator; the T4 doctor pre-flight gates the paid loop (plan M6 resolves
|
||||
to the honest branch).
|
||||
- Doctor `--json` also enumerates config **sources** with per-source status —
|
||||
`~/.grok/config.toml`, `~/.claude.json`, `.mcp.json` — and each server carries a
|
||||
`"source"` field (`"config"`, `".mcp.json"`, …): the T2b provenance assertion reads
|
||||
this directly.
|
||||
- `grok mcp list --json` → exit 0, array of `{command, args, env, enabled, name,
|
||||
scope}`.
|
||||
- `grok inspect` (keyless, exit 0) shows version, CWD, `Project trusted: yes/no`,
|
||||
instructions, permissions, skills, agents — the config-discovery audit surface.
|
||||
|
||||
## Vendor-config fallback — TRUST-GATED (verified)
|
||||
A project `.mcp.json` in the cwd is SEEN by doctor (source `found`, server listed
|
||||
with `source: ".mcp.json"`) but the server check reports **`folder untrusted`** and
|
||||
`mcp list` shows nothing until the folder is trusted (first-run trust flow). So:
|
||||
fresh tmp HOME + fresh cwd ⇒ vendor entries structurally cannot activate (door
|
||||
provenance guarantee), and on an operator's machine the fallback only engages for
|
||||
folders they already trusted — the live-lane warning (operator `~/.claude.json`
|
||||
carrying `mcpServers.gbrain`) still applies for trusted folders.
|
||||
|
||||
## When the door goes red (triage)
|
||||
| Failure class | Signature | Remediation |
|
||||
|---|---|---|
|
||||
| npm pin drift | install step: version/integrity mismatch | Re-pin deliberately: bump `npm_version`+`npm_integrity` stamps here, re-run the re-observation checklist below, update workflow env pins (check-grok-pin.sh enforces the pair) |
|
||||
| installer digest drift (fallback path) | `sha256sum -c` fails on install.sh | Diff the new installer, re-pin `installer_sha256` after review |
|
||||
| version drift mid-run | `grok --version` re-check ≠ pinned | Auto-update engaged — verify `[cli] auto_update = false` seeding; re-pin if a deliberate bump |
|
||||
| blank XAI_API_KEY secret | named precondition/paid-sentinel failure | Admin adds/rotates the repo Actions secret (console.x.ai origin); keyless tier still ran |
|
||||
| invalid/expired key | bad-key preflight fails (pin its message after first authed run) | Rotate the secret; no code change |
|
||||
| tripwire fired | manifest mismatch on config/credential files only | True isolation breach — stop, inspect which file changed; volatile-path drift alone must NOT fire (bug in exclusions if it does) |
|
||||
| real door regression | doctor checks or recall assert fail with pins intact | Bisect against the pinned version; file upstream if grok-side |
|
||||
|
||||
Re-observation checklist on a version bump: re-run the npm/installer pin captures
|
||||
(§Pin), the help-surface diff (`--help`, `mcp --help`, `mcp add --help`), and the
|
||||
mcp add → saved-TOML → doctor sequence (§add/§probes). The one-shot/auth/model
|
||||
sections only need re-observation if their assertions start failing.
|
||||
|
||||
## Keyless TUI behavior (observed via the dx-explore PTY instrument)
|
||||
Under a real PTY with no credentials, interactive `grok` plays a Braille-
|
||||
pattern intro animation (U+2800-range glyphs) for a few seconds, then settles
|
||||
(~6s) onto a SIGN-IN screen: "Approve in your browser to finish signing in"
|
||||
plus a device code (and a ctrl+c hint). There is no unattended path past it.
|
||||
Two hazards for PTY automation, both observed: the animation frames carry
|
||||
zero word-like text (3+-letter runs) — a text-presence heuristic must count
|
||||
letter runs, not enumerate glyphs; and pasting into the sign-in screen leaves
|
||||
a persistent full-screen spinner redrawing at ~5 frames/sec, which starves
|
||||
quiet-based settling and makes full-buffer ANSI stripping the hot loop
|
||||
(strip bounded raw tails instead). Headless keyless is the clean
|
||||
`Not signed in` error above. The `grok-install` dx scenario early-stops at
|
||||
the sign-in copy (or a persistently textless screen) with the friction
|
||||
recorded — that IS the keyless measurement.
|
||||
|
||||
## Supported-version policy
|
||||
gbrain's grok integration is verified against **Grok Build v1.0.4** (this pin). The
|
||||
canary CI leg (enabled with the secret) tracks latest and is continue-on-error; the
|
||||
pinned lane is the deterministic gate. **Pending auth** (requires `XAI_API_KEY`):
|
||||
paid one-shot smoke, authed model list + per-turn cost pins, credential-file
|
||||
inventory after login (feeds evidence exclusions + TTY secretPaths), AUTHED
|
||||
first-run TUI dialog copy (the keyless TUI + headless copies are pinned above).
|
||||
@@ -0,0 +1,155 @@
|
||||
# Connect GBrain to Grok Build
|
||||
|
||||
> This page is the MCP-registration reference for **Grok Build** — xAI's
|
||||
> official `grok` CLI (early beta, subscriber-gated; not the community
|
||||
> `superagent-ai/grok-cli`, which ships a colliding `grok` binary — see
|
||||
> Troubleshooting). 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 Grok Build over stdio MCP.
|
||||
> The `gbrain bootstrap` persistent-personal-agent path is **not yet
|
||||
> supported for Grok** (Claude Code and Codex only today) — brain-only
|
||||
> install is what this page delivers.
|
||||
|
||||
Grok Build 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
|
||||
grok mcp add gbrain -e "GBRAIN_HOME=$HOME" -- gbrain serve --surface verbs
|
||||
```
|
||||
|
||||
`--surface verbs` exposes 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
|
||||
100+-op catalog — the recommended starting surface for coding agents.
|
||||
Three facts about `grok mcp add`, all observed:
|
||||
|
||||
- **The env flag is repeatable, one `KEY=value` per flag** (`-e A=1 -e B=2`).
|
||||
Server argv goes after `--`.
|
||||
- **Registration is lazy.** The add writes config and exits 0 without
|
||||
connecting — even for a nonexistent command. Verify with `grok mcp doctor`
|
||||
(below), never with the add's exit code.
|
||||
- **Scope:** the default writes to `~/.grok/config.toml`; add `-s project`
|
||||
to write a committable `./.grok/config.toml` instead (reference secrets as
|
||||
`${VAR}` in project scope — values are stored verbatim).
|
||||
|
||||
## Direct config (equally supported)
|
||||
|
||||
The add command writes an `[mcp_servers.gbrain]` block into
|
||||
`~/.grok/config.toml` (or `./.grok/config.toml` with project scope; the
|
||||
`GROK_HOME` env var relocates the user config dir). You can write it
|
||||
yourself instead:
|
||||
|
||||
```toml
|
||||
[mcp_servers.gbrain]
|
||||
command = "gbrain"
|
||||
args = ["serve", "--surface", "verbs"]
|
||||
startup_timeout_sec = 60
|
||||
enabled = true
|
||||
|
||||
[mcp_servers.gbrain.env]
|
||||
GBRAIN_HOME = "/home/alice-example"
|
||||
```
|
||||
|
||||
`startup_timeout_sec` defaults to 30; raise it (or export
|
||||
`GROK_MCP_STARTUP_TIMEOUT_SECS`) if gbrain runs from source via `bun run`,
|
||||
which cold-transpiles on first spawn. To remove gbrain, delete the block (or
|
||||
set `enabled = false` to disable without losing the config).
|
||||
|
||||
## Zero-config vendor fallback
|
||||
|
||||
Grok Build also reads MCP registrations from `~/.claude.json`, `.cursor/mcp.json`,
|
||||
and a project `.mcp.json` — at lower priority than its own config, and **only
|
||||
for folders you have trusted** in Grok (fresh folders report
|
||||
`folder untrusted` until you accept the trust prompt). If you already
|
||||
registered gbrain for Claude Code, Grok may pick it up with zero
|
||||
configuration. `grok mcp doctor --json` reports every source it consulted
|
||||
and which one each server came from — check the `source` field to see which
|
||||
config won before assuming the native one did.
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
grok mcp list --json # entry: {"name":"gbrain","enabled":true,...}
|
||||
grok mcp doctor gbrain # THE real probe: spawns the server
|
||||
```
|
||||
|
||||
`grok mcp doctor gbrain` performs the actual handshake — expect the checks
|
||||
`command found`, `server started`, `handshake OK`, and `7 tools discovered`
|
||||
(the seven verbs), exit 0. A broken registration exits 1 with a failing
|
||||
check and a hint. Then one real round-trip:
|
||||
|
||||
```bash
|
||||
grok -p "use the gbrain recall tool to answer: what did I import most recently?"
|
||||
```
|
||||
|
||||
`grok -p` (single-turn headless) prints the final answer on stdout.
|
||||
|
||||
## Headless auth + model pin
|
||||
|
||||
For cron jobs, CI, or any non-TTY run:
|
||||
|
||||
- **Auth:** export `XAI_API_KEY` (from console.x.ai). Keyless headless runs
|
||||
exit 1 with `Not signed in`; `grok login --device-code` is the
|
||||
interactive-terminal alternative, `grok login` the browser one.
|
||||
- **Model pin:** pass `-m <model>` per call, or set it in config:
|
||||
|
||||
```toml
|
||||
[models]
|
||||
default = "grok-4.5"
|
||||
```
|
||||
|
||||
- **Updates:** Grok self-updates by default. For pinned/reproducible
|
||||
environments, seed:
|
||||
|
||||
```toml
|
||||
[cli]
|
||||
auto_update = false
|
||||
```
|
||||
|
||||
## Pair with cron
|
||||
|
||||
Grok Build has no built-in cron; schedule headless one-shots with your
|
||||
system scheduler:
|
||||
|
||||
```bash
|
||||
# crontab: brain maintenance every 4 hours
|
||||
0 */4 * * * XAI_API_KEY=... grok -p "Run gbrain sync and report anything unusual" --output-format plain
|
||||
```
|
||||
|
||||
See [docs/guides/cron-schedule.md](../guides/cron-schedule.md) for the full
|
||||
brain maintenance protocol (sync, embed, dream cycle).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Wrong `grok` on PATH** — the community `superagent-ai/grok-cli` also
|
||||
installs a `grok` binary. The official CLI answers `grok --version` with
|
||||
`grok X.Y.Z (buildhash)`; anything else is the other tool. Install the
|
||||
official one via `npm install -g @xai-official/grok` or
|
||||
`curl -fsSL https://x.ai/cli/install.sh | bash`.
|
||||
- **grok ≠ groq ≠ ngrok** — Grok Build (xAI, `XAI_API_KEY`) is not Groq
|
||||
(the inference provider, `GROQ_API_KEY`) and not ngrok (tunnels). A
|
||||
mis-set key produces auth errors against the wrong service.
|
||||
- **`Not signed in` (exit 1)** — no auth in a headless run. Export
|
||||
`XAI_API_KEY` or run `grok login --device-code`.
|
||||
- **Doctor says `folder untrusted`** — the registration came from a vendor
|
||||
config (`.mcp.json` / `~/.claude.json`) in a folder Grok hasn't been told
|
||||
to trust. Trust the folder in an interactive session, or register
|
||||
natively with `grok mcp add`.
|
||||
- **Doctor times out on `server started`** — raise `startup_timeout_sec`
|
||||
(or `GROK_MCP_STARTUP_TIMEOUT_SECS=90`) if gbrain runs via `bun run`.
|
||||
- **Skills note:** `gbrain skillpack scaffold` writes `skills/<name>/SKILL.md`
|
||||
into your workspace, which Grok does **not** auto-discover as Grok skills
|
||||
(it reads `.grok/skills`, `~/.grok/skills`, `~/.agents/skills`, plugins).
|
||||
gbrain's skills still work as reference documents the agent reads;
|
||||
`grok inspect` shows what Grok actually discovered.
|
||||
- **`grok inspect`** — the config-discovery audit: version, cwd trust,
|
||||
instructions, permissions, skills, agents, MCP sources.
|
||||
|
||||
---
|
||||
|
||||
Verified against **Grok Build v1.0.4** (early beta — expect churn; the pin
|
||||
is enforced in CI). Dev-facing observed-behavior notes (exact flag
|
||||
semantics, exit-code caveats, config schema, CI pin values) live in
|
||||
[GROK-CLI-PIN.md](GROK-CLI-PIN.md).
|
||||
@@ -0,0 +1,115 @@
|
||||
# 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: `868ed3a91e0fabbff6d7418b3ede82bf4833652ec4e77196a42852fb35a9e5b9`
|
||||
(refreshed 2026-08-15: upstream installer drifted past the prior pin —
|
||||
reviewed; the `--commit` payload-pin path the door depends on is intact,
|
||||
and the payload pins (tag+commit) are unchanged)
|
||||
(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: "868ed3a91e0fabbff6d7418b3ede82bf4833652ec4e77196a42852fb35a9e5b9"`
|
||||
- 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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -0,0 +1,252 @@
|
||||
# opencode CLI pin — observed behavior notes (v1.18.18)
|
||||
|
||||
Dev-facing companion to [OPENCODE.md](OPENCODE.md): every fact below was OBSERVED
|
||||
against a real hermetic install (2026-08-15, macOS arm64), not researched from docs.
|
||||
The claw-test OpencodeRunner, the install door e2e, and the heavy-tests
|
||||
opencode-door CI job assert exactly these shapes — when opencode releases change
|
||||
them, update this file, the workflow pins, and the affected assertions together
|
||||
(`scripts/check-opencode-pin.sh` in `bun run verify` enforces the workflow-side
|
||||
match). Where an observation CONTRADICTS opencode's docs, the observation wins and
|
||||
the contradiction is called out inline.
|
||||
|
||||
Naming note: **opencode** (SST, opencode.ai, npm `opencode-ai`) is not **OpenClaw**
|
||||
(the agent platform gbrain ships a runner for) and not the original `opencode` CLI
|
||||
that was renamed Crush — see Troubleshooting in OPENCODE.md for the binary-name
|
||||
collision.
|
||||
|
||||
<!-- opencode-pin: distribution_kind=npm -->
|
||||
<!-- opencode-pin: npm_package=opencode-ai -->
|
||||
<!-- opencode-pin: npm_version=1.18.18 -->
|
||||
<!-- opencode-pin: npm_integrity=sha512-J+5HFq8tf+wPBBpBpMPSNjSytF2/EkNWYfFZh4si1d9auFbQriqDyqZv+vFUsLWERfdMU32Eajwuiq3rKBvZLQ== -->
|
||||
<!-- opencode-pin: npm_linux_x64_integrity=sha512-WmeUnhljYJ252wywKTiW4bNDzsas2njpjPUEh0jM6HKNI4vFxJtREtzaWViY4AKEAcOkLWT8Ll17ixvcHz3AnA== -->
|
||||
<!-- opencode-pin: npm_linux_arm64_integrity=sha512-e8D3g0qJEIzawEg2+ygW3vkZjAYL2ssyAx4GbihjwXwZFvlZZy5zRWWzdz5KLBoHSTl0FB73vNtnNeXONyHpVQ== -->
|
||||
<!-- opencode-pin: opencode_version=1.18.18 -->
|
||||
<!-- opencode-pin: observed_date=2026-08-15 -->
|
||||
|
||||
## Pin
|
||||
- **opencode v1.18.18**, `opencode --version` output shape: bare `1.18.18` —
|
||||
version only, NO binary-name prefix, NO build hash (unlike grok's
|
||||
`grok 1.0.4 (hash)`). The door's T1 shape assert is `/^\d+\.\d+\.\d+$/` on the
|
||||
trimmed output; SST identity is discriminated by the `mcp`+`debug` subcommands
|
||||
existing (`opencode debug paths` exits 0 and prints the path table below —
|
||||
the renamed-to-Crush ancestor and other claimants have neither).
|
||||
- **Provisioning (CI + local): pinned npm, pack-verify-install** —
|
||||
`opencode-ai@1.18.18`, registry integrity `sha512-J+5HFq…`. The CI job
|
||||
`npm pack`s the wrapper AND the runner's platform payload first (pack
|
||||
reports the integrity of the bytes it actually downloaded — closing the
|
||||
view-then-install TOCTOU), asserts both against the stamps above, then
|
||||
installs FROM the verified local wrapper tarball; the install-time platform
|
||||
sub-package fetch is validated by npm against the same packument integrity
|
||||
the pack step just byte-confirmed. The wrapper fans out to per-platform
|
||||
payloads (`opencode-{darwin,linux,windows}-{arm64,x64}[-baseline|-musl]`) as
|
||||
optionalDependencies at the same version; the LINUX payload integrities are
|
||||
pinned separately because the wrapper's integrity covers only the wrapper
|
||||
tarball. Darwin arm64 payload observed at
|
||||
`sha512-VkG+bz8u8Xqg9NzPK+2/71nEd4DKKlo2NLZurQ1eLAzDnmb1CMYZif/o6Shl8YFuTuYU/30k6yufl4Zr0Ij64g==`
|
||||
(informational — the CI runners are linux). Same npm version-immutability
|
||||
assumption as the grok pin, stated explicitly.
|
||||
- A curl installer (`https://opencode.ai/install`) exists but is NOT the pinned
|
||||
lane; npm is.
|
||||
|
||||
## Pin-refresh cadence (this CLI ships near-continuously)
|
||||
opencode releases far faster than grok (patch releases near-daily). The pinned
|
||||
lane is the deterministic gate; the **canary leg** in `opencode-door` (schedule-
|
||||
scoped, `continue-on-error`, installs `opencode-ai@latest`) exists to surface
|
||||
drift BEFORE it strands the pin. Policy: when the canary leg reds or the pin is
|
||||
>6 weeks old, run the re-observation checklist (bottom) against latest, bump the
|
||||
stamps + workflow env pins together, and note behavior deltas in this file.
|
||||
Do not chase every patch release; refresh on canary signal or the 6-week clock.
|
||||
|
||||
## Path seams — XDG honored; OPENCODE_CONFIG* env vars are INERT (verified)
|
||||
`opencode debug paths` is the authoritative dump. Observed under
|
||||
`HOME=<tmp> XDG_CONFIG_HOME=<tmp>/.config XDG_DATA_HOME=<tmp>/.local/share`:
|
||||
|
||||
```
|
||||
config <XDG_CONFIG_HOME>/opencode (opencode.json + opencode.jsonc)
|
||||
data <XDG_DATA_HOME>/opencode (auth.json, opencode.db*, log/, repos/)
|
||||
state <tmp>/.local/state/opencode (locks/)
|
||||
cache <tmp>/.cache/opencode (bin/)
|
||||
tmp /tmp/opencode
|
||||
```
|
||||
|
||||
- **HOME + XDG_CONFIG_HOME/XDG_DATA_HOME redirection works fully on macOS**
|
||||
(nothing was written outside the hermetic home across the whole observation
|
||||
run). The door uses HOME + both XDG vars, belt-and-suspenders.
|
||||
- **DOCS-CONTRADICTION: `OPENCODE_CONFIG`, `OPENCODE_CONFIG_DIR`, and
|
||||
`OPENCODE_CONFIG_CONTENT` had NO observable effect on config resolution in
|
||||
1.18.18** — probes registered via each were absent from `mcp list`, while the
|
||||
XDG-resolved global config was still read. gbrain's path helpers therefore
|
||||
resolve via XDG only and deliberately do NOT honor `OPENCODE_CONFIG*`;
|
||||
re-observe on version bump (if a future release activates them, the helpers
|
||||
and this section change together). Hermetic child envs still DELETE all three
|
||||
(defense against a future release activating them).
|
||||
- Volatile paths (tripwire exclusions): `opencode.db`, `opencode.db-shm`,
|
||||
`opencode.db-wal`, `log/`, `repos/` under data; `locks/` under state; `bin/`
|
||||
under cache. The tripwire hashes only `opencode.json(c)` + `auth.json`.
|
||||
- Vendor quirk: opencode writes a `.gitignore` (node_modules, package.json, …)
|
||||
into the CONFIG dir on first touch.
|
||||
|
||||
## Config format — JSONC everywhere, both filenames merge (verified)
|
||||
- `~/.config/opencode/opencode.jsonc` AND `~/.config/opencode/opencode.json`
|
||||
are BOTH read when both exist (servers from each appeared simultaneously in
|
||||
`mcp list`) — merge, not first-wins. opencode's own `mcp add` writes the
|
||||
`.jsonc` name.
|
||||
- **Comments parse in `.json`-named files too** (a `// comment` inside project
|
||||
`opencode.json` did not break resolution). JSONC is the effective grammar for
|
||||
every config file regardless of extension → gbrain's writer treats all
|
||||
opencode configs as JSONC (jsonc-parser surgical edits; comments survive).
|
||||
- Project config: `opencode.json` in the project root is read (lookup traverses
|
||||
up); a project-scope entry appears alongside global entries.
|
||||
- Unknown keys inside an `mcp.<name>` entry are TOLERATED in 1.18.18 (an
|
||||
`_gbrain` probe key neither errored nor hid the server). gbrain still does
|
||||
NOT write marker keys — ownership is judged by structural fingerprint — so a
|
||||
future strict-schema flip cannot brick a user's opencode.
|
||||
- `opencode debug config` prints the resolved merge (rendering has a doubled-
|
||||
line quirk; treat it as a debug view, not a parse surface).
|
||||
|
||||
## `opencode mcp add` — observed facts
|
||||
- Shape: `opencode mcp add <name> [--env KEY=VALUE]... -- <command> [args...]`
|
||||
(local) or `opencode mcp add <name> --url <URL> [--header KEY=VALUE]...`
|
||||
(remote). The `-- command` form is real but UNDOCUMENTED in `--help` (the
|
||||
help lists only `--url/--env/--header`; the error copy for a bare add says
|
||||
`Provide either --url <url> or a command after --`).
|
||||
- **Always writes the GLOBAL `opencode.jsonc`** — even when a project
|
||||
`opencode.json` with an `mcp` table exists in the cwd. There is NO scope
|
||||
flag. Project-scope registration requires writing the file directly (gbrain's
|
||||
writer does).
|
||||
- **Add is lazy**: exit 0, no spawn, no prompt — for unreachable URLs and
|
||||
nonexistent commands alike. Never treat add's exit code as a handshake.
|
||||
- **Rewrites preserve comments and foreign keys** (a seeded `// comment` and a
|
||||
`theme` key survived a subsequent add) — opencode uses a JSONC-preserving
|
||||
editor internally; gbrain's writer matches that bar.
|
||||
- `--header` values are stored verbatim, including `{env:VAR}` interpolation
|
||||
syntax (`Authorization=Bearer {env:GBRAIN_REMOTE_TOKEN}` round-trips).
|
||||
|
||||
## Saved config schema (verbatim, from real adds)
|
||||
```jsonc
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"mcp": {
|
||||
"gbrain": {
|
||||
"type": "local",
|
||||
"command": ["gbrain", "serve", "--surface", "verbs"],
|
||||
"environment": { "GBRAIN_SOURCE": "workspace", "GBRAIN_HOME": "/tmp/<brain-home>" }
|
||||
},
|
||||
"gbrain-remote": {
|
||||
"type": "remote",
|
||||
"url": "https://brain.example/mcp",
|
||||
"headers": { "Authorization": "Bearer {env:GBRAIN_REMOTE_TOKEN}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
`enabled` is optional (absent = enabled). `oauth` was not written by the CLI and
|
||||
is omitted by gbrain's writer (no OAuth interference with bearer headers was
|
||||
observed). Local commands: an absolute `command[0]` works; PATH-resolved bare
|
||||
`gbrain` resolves via the SPAWNING process's PATH (the door verifies the staged
|
||||
bin-dir prepend).
|
||||
|
||||
## Probes — `mcp list` is the honest discriminator; `mcp debug` is NOT
|
||||
- **`opencode mcp list` SPAWNS every configured local server and connects every
|
||||
remote one**, then prints per-server status: `✓ <name> connected` or
|
||||
`✗ <name> failed` with a reason line (`Executable not found in $PATH:
|
||||
"gbrain"`, `SSE error: …`). THE door's keyless handshake proof. Caveats:
|
||||
**exit code is 0 even when servers fail** (parse the text, assert
|
||||
`✓ gbrain connected`), output is clack-style UI with ANSI codes, and there is
|
||||
no `--json`.
|
||||
- **`mcp list` is also a code-execution surface**: it spawned a PROJECT-defined
|
||||
`type:local` command from a fresh checkout with NO prompt and NO trust gate
|
||||
(verified with a touch-file probe). Two consequences: (1) gbrain's
|
||||
bootstrap default scope for opencode is USER-GLOBAL — a committed project
|
||||
entry would auto-spawn on every collaborator's machine; (2) any gbrain-run
|
||||
probe uses `--pure` (kills external plugin autoload) + `OPENCODE_DISABLE_AUTOUPDATE=1`.
|
||||
- `opencode mcp debug <name>` is OAUTH debugging only — on a local server it
|
||||
prints `MCP server <name> is not a remote server` and exits 0. Not a
|
||||
discriminator.
|
||||
- No tool-count line exists in `mcp list` (grok's `7 tools discovered` has no
|
||||
analog); tool discovery is proven by the SMOKE turn's `tool_use` events
|
||||
instead.
|
||||
|
||||
## One-shot (`opencode run`) — KEYLESS WORKS (anonymous free tier)
|
||||
- `opencode run "<msg>"` prints the ANSWER TEXT ALONE on stdout; the session
|
||||
banner (`> build · <model>`) and UI go to stderr. Exit 0 on success; exit 1
|
||||
with a structured JSON error (`"ref": "err_…"`) on failure (e.g. bogus
|
||||
model).
|
||||
- **Keyless runs WORK**: with zero credentials and no auth.json, `run` answers
|
||||
via opencode's anonymous free tier (default model observed:
|
||||
`opencode/big-pickle`; `opencode models` lists 8 keyless `opencode/*` models,
|
||||
most `-free` suffixed; `opencode stats` reports $0.00). There is no
|
||||
`Not signed in` wall in headless run mode.
|
||||
- **MCP tools fire in keyless run mode WITHOUT `--auto`** (verified: the free
|
||||
model called `gbrain_recall` and returned a seeded per-run nonce with
|
||||
`--auto` absent). `--auto` exists (`auto-approve permissions that are not
|
||||
explicitly denied (dangerous!)`) but the door does not need or use it.
|
||||
- MCP tool naming: `<server>_<tool>` (observed `gbrain_recall`).
|
||||
- `--format json` emits NDJSON events, every event
|
||||
`{type, timestamp, sessionID, part}`; types observed: `step_start`,
|
||||
`tool_use`, `text`, `step_finish`. Tool events carry
|
||||
`part: {type:"tool", tool:"gbrain_recall", callID, state:{status:"completed",
|
||||
input:{…}, output:"<stringified JSON>"}}` — `parseOpencodeJsonl` pins this.
|
||||
- Model flag: `-m/--model <provider/model>` (`opencode/big-pickle` confirmed;
|
||||
paid ids follow models.dev convention — see Pending auth).
|
||||
- Keyless SMOKE end-to-end (proven 2026-08-15): pinned opencode + free model +
|
||||
real `gbrain serve --surface verbs` (7 verbs banner) recalled a per-run nonce
|
||||
through MCP with zero credentials, keyless PGLite brain.
|
||||
|
||||
## Environment — detectHarness + child-env facts (verified)
|
||||
- Inside `run`'s bash tool, opencode sets **`OPENCODE=1`** and `OPENCODE_PID`
|
||||
in child processes → `gbrain bootstrap`'s `detectHarness()` probes
|
||||
`OPENCODE`.
|
||||
- Auto-update kill: `OPENCODE_DISABLE_AUTOUPDATE=1` env + `"autoupdate": false`
|
||||
config — the door seeds BOTH; version stayed pinned across every observed
|
||||
run. `opencode upgrade` is the manual updater.
|
||||
- Rules files: project `AGENTS.md` is loaded; a sibling `CLAUDE.md` is NOT
|
||||
double-loaded (nonce test: only the AGENTS.md nonce surfaced) — AGENTS.md
|
||||
wins per level, exactly as documented. gbrain's rendered pull-protocol
|
||||
contract works unchanged.
|
||||
- `.well-known/opencode` remote config: never observed to fire in any CLI run
|
||||
(docs list it atop the lookup order). No kill needed today; re-observe on
|
||||
version bump.
|
||||
|
||||
## Auth (only needed for PAID providers)
|
||||
- Anonymous free tier needs nothing on disk; `auth.json` is only created by
|
||||
`opencode auth login` at `<XDG_DATA_HOME>/opencode/auth.json`
|
||||
(`opencode providers`, alias `auth`, prints the path).
|
||||
- The optional paid door leg gates on `ANTHROPIC_API_KEY` (env-only) and
|
||||
self-validates the model id against the authed `opencode models` output
|
||||
before spending.
|
||||
|
||||
## When the door goes red (triage)
|
||||
| Failure class | Signature | Remediation |
|
||||
|---|---|---|
|
||||
| npm pin drift | install step: version/integrity mismatch | Re-pin deliberately: bump `npm_version`+`npm_integrity` (+ platform stamps), run the re-observation checklist, update workflow env pins (check-opencode-pin.sh enforces the pair) |
|
||||
| canary leg red, pinned leg green | latest-version leg fails install/asserts | Upstream changed shape — schedule a pin refresh; pinned lane still gates |
|
||||
| version drift mid-run | `opencode --version` re-check ≠ pinned | Auto-update engaged — verify BOTH kills (env + config seed); re-pin if deliberate |
|
||||
| `✗ gbrain failed` in `mcp list` | `Executable not found in $PATH` / spawn error | Staged bin dir missing from PATH, or abs path wrong — registration bug, not opencode drift |
|
||||
| free-tier drift | keyless SMOKE stops answering / new auth wall | Re-observe keyless posture; if the free tier is gated, flip the SMOKE to the ANTHROPIC leg and re-pin this section |
|
||||
| paid leg: model id unknown | models-gate assert fails before any spend | Update the pinned anthropic model id from the authed `opencode models` output |
|
||||
| tripwire fired | manifest mismatch on `opencode.json(c)`/`auth.json` only | True isolation breach — stop and inspect; volatile-path drift alone must NOT fire |
|
||||
| real door regression | handshake or nonce assert fails, pins intact | Bisect against the pinned version; file upstream if opencode-side |
|
||||
|
||||
Re-observation checklist on a version bump: npm pin captures (§Pin), help-surface
|
||||
diff (`--help`, `run --help`, `mcp --help`, `mcp add --help`), the
|
||||
add → saved-config → `mcp list` sequence (§add/§Probes), the keyless `run`
|
||||
posture (§One-shot — free tier presence, stdout purity, MCP-without---auto),
|
||||
`debug paths`, and the `OPENCODE_CONFIG*` inertness probe (§Path seams). The
|
||||
spawn-gate probe (§Probes) re-runs whenever release notes mention MCP trust or
|
||||
permissions.
|
||||
|
||||
## Pending auth (requires ANTHROPIC_API_KEY; the core door does NOT)
|
||||
Authed `opencode models` list + exact `anthropic/<model>` id confirmation,
|
||||
one paid one-shot smoke + per-turn cost note, `auth.json` verbatim shape after
|
||||
`opencode auth login` (feeds evidence exclusions + TTY secretPaths), and
|
||||
whether the authed TUI first-run differs from the keyless one pinned in the
|
||||
dx scenario. The opencode-door paid leg self-validates the model id before
|
||||
spending, so these pins harden the door but do not block it.
|
||||
|
||||
## Supported-version policy
|
||||
gbrain's opencode integration is verified against **opencode v1.18.18** (this
|
||||
pin). The canary CI leg tracks latest (continue-on-error); the pinned lane is
|
||||
the deterministic gate. Keyless free-tier behavior is a LOAD-BEARING
|
||||
observation (the SMOKE rides it) — treat free-tier changes as pin-refresh
|
||||
triggers, not flakes.
|
||||
@@ -0,0 +1,175 @@
|
||||
# Connect GBrain to opencode
|
||||
|
||||
> This page is the MCP-registration reference for **opencode** — the SST
|
||||
> terminal coding agent (opencode.ai, npm `opencode-ai`; not OpenClaw, and not
|
||||
> the original `opencode` CLI that was renamed Crush — see Troubleshooting).
|
||||
> 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 opencode over stdio MCP. opencode is a
|
||||
> **bootstrap-supported harness**: `gbrain bootstrap hooks --harness opencode`
|
||||
> registers the brain for you (and `gbrain connect --agent opencode` handles
|
||||
> remote brains — see below) — the commands on this page are the standalone
|
||||
> manual recipe. Bootstrap's own registration additionally pins the workspace
|
||||
> source (`GBRAIN_SOURCE`) and the full op surface, so the two are not
|
||||
> byte-identical.
|
||||
|
||||
opencode spawns `gbrain serve` as a local stdio subprocess. No server, no
|
||||
tunnel, no token needed. Works with both PGLite and Supabase engines — and
|
||||
because opencode natively reads `AGENTS.md`, a gbrain workspace's rendered
|
||||
brain contract loads with zero extra configuration.
|
||||
|
||||
## Register (recommended)
|
||||
|
||||
```bash
|
||||
opencode mcp add gbrain --env GBRAIN_HOME=$HOME -- gbrain serve --surface verbs
|
||||
```
|
||||
|
||||
`--surface verbs` exposes 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
|
||||
100+-op catalog — the recommended starting surface for coding agents.
|
||||
Three facts about `opencode mcp add`, all observed:
|
||||
|
||||
- **The local-command form is `-- <command> [args...]` after the flags** —
|
||||
it's real but missing from `--help` (which shows only `--url/--env/--header`).
|
||||
`--env` is repeatable, one `KEY=VALUE` per flag.
|
||||
- **Registration is lazy.** The add writes config and exits 0 without
|
||||
connecting — even for a nonexistent command. Verify with `opencode mcp list`
|
||||
(below), never with the add's exit code.
|
||||
- **It always writes the USER-GLOBAL config**
|
||||
(`~/.config/opencode/opencode.jsonc`) — there is no scope flag. For a
|
||||
project-scoped entry, write the project `opencode.json` directly (next
|
||||
section) — but read the sharing warning first.
|
||||
|
||||
## Direct config (equally supported)
|
||||
|
||||
Global (`~/.config/opencode/opencode.jsonc`) or project (`opencode.json` in
|
||||
the repo root — opencode's lookup traverses up to the git root):
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"mcp": {
|
||||
"gbrain": {
|
||||
"type": "local",
|
||||
"command": ["gbrain", "serve", "--surface", "verbs"],
|
||||
"environment": { "GBRAIN_HOME": "/home/alice-example" },
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Comments are fine — opencode parses JSONC in both `.json` and `.jsonc` files,
|
||||
and both filenames are read (merged) when both exist. To remove gbrain,
|
||||
delete the entry, or set `"enabled": false` to disable without losing it.
|
||||
|
||||
**Sharing warning for project config:** opencode spawns project-defined local
|
||||
MCP servers with **no trust prompt** — a committed `opencode.json` carrying a
|
||||
gbrain entry executes on every collaborator's machine. Teammates without
|
||||
gbrain get a failing spawn each session; teammates WITH gbrain attach their
|
||||
own `host` brain to your repo's context. Prefer the user-global config (the
|
||||
gbrain bootstrap default); if you do commit a project entry, use the
|
||||
PATH-resolved `"gbrain"` command form (never an absolute path) and tell
|
||||
collaborators `"enabled": false` is the opt-out.
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
opencode mcp list # the real probe: SPAWNS the server
|
||||
```
|
||||
|
||||
`opencode mcp list` performs the actual spawn + handshake for every
|
||||
configured server — expect `✓ gbrain connected`. A broken registration shows
|
||||
`✗ gbrain failed` with the reason (e.g. `Executable not found in $PATH`).
|
||||
Because it spawns everything — including any project `opencode.json` entries
|
||||
in your cwd, with no trust prompt — run it from a directory you trust
|
||||
(gbrain's own bootstrap verification probe runs from an empty temp directory
|
||||
for exactly this reason, and skips the live probe entirely for project-scoped
|
||||
registrations).
|
||||
Two caveats: the exit code is 0 even when servers fail (read the output, not
|
||||
`$?`), and `opencode mcp debug` is OAuth-only diagnostics — it is NOT a
|
||||
handshake probe for local servers. Then one real round-trip:
|
||||
|
||||
```bash
|
||||
opencode run "use the gbrain recall tool to answer: what did I import most recently?"
|
||||
```
|
||||
|
||||
`opencode run` (headless one-shot) prints the final answer alone on stdout
|
||||
(UI goes to stderr). MCP tools work in run mode without any permission flags.
|
||||
|
||||
## Remote brains (`gbrain connect`)
|
||||
|
||||
For a brain served over HTTP on another machine:
|
||||
|
||||
```bash
|
||||
gbrain connect https://your-host/mcp --token gbrain_xxx --agent opencode [--install]
|
||||
```
|
||||
|
||||
Without `--install` it prints the config block to add; with `--install` it
|
||||
writes the entry directly into the user-global config (no opencode binary
|
||||
required — the JSONC write IS the registration) and smoke-tests the token.
|
||||
Either way the config stores only the `{env:GBRAIN_REMOTE_TOKEN}`
|
||||
interpolation — opencode resolves the env var at read time, so the token
|
||||
never lands in the file. Export `GBRAIN_REMOTE_TOKEN` in your shell profile.
|
||||
`--force` replaces a gbrain-managed entry whose endpoint moved (a rotated
|
||||
serve); an entry gbrain didn't write is never replaced — pick another
|
||||
`--name`. (Framework-spawned opencode inherits no shell profile;
|
||||
`gbrain bootstrap harness --harness opencode` covers that case with an
|
||||
inline-bearer entry written 0600.)
|
||||
|
||||
## Auth + model pin
|
||||
|
||||
- **Keyless works.** opencode ships an anonymous free tier (default model
|
||||
`opencode/big-pickle` at observation time) — headless runs and MCP tool
|
||||
calls work with zero credentials. For paid providers, export the provider
|
||||
key (e.g. `ANTHROPIC_API_KEY`) or run `opencode auth login` (credentials
|
||||
land in `~/.local/share/opencode/auth.json`).
|
||||
- **Model pin:** pass `-m <provider/model>` per call, or set `"model"` in the
|
||||
config. `opencode models` lists what your credentials can reach.
|
||||
- **Updates:** opencode self-updates by default. For pinned/reproducible
|
||||
environments, set BOTH `"autoupdate": false` in config AND
|
||||
`OPENCODE_DISABLE_AUTOUPDATE=1` in the environment.
|
||||
|
||||
## Pair with cron
|
||||
|
||||
opencode has no built-in cron; schedule headless one-shots with your system
|
||||
scheduler:
|
||||
|
||||
```bash
|
||||
# crontab: brain maintenance every 4 hours
|
||||
0 */4 * * * opencode run "Run gbrain sync and report anything unusual"
|
||||
```
|
||||
|
||||
See [docs/guides/cron-schedule.md](../guides/cron-schedule.md) for the full
|
||||
brain maintenance protocol (sync, embed, dream cycle).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Wrong `opencode` on PATH** — the name has prior claimants (the original
|
||||
`opencode` project was renamed Crush). The SST CLI answers
|
||||
`opencode --version` with a bare semver (`1.18.18`) and has `opencode mcp`
|
||||
+ `opencode debug paths` subcommands. Install it via
|
||||
`npm install -g opencode-ai` or `curl -fsSL https://opencode.ai/install | bash`.
|
||||
- **opencode ≠ OpenClaw** — opencode (opencode.ai / SST) is the terminal
|
||||
agent this page covers; OpenClaw is the agent platform with its own gbrain
|
||||
runner and docs ([OPENCLAW.md](OPENCLAW.md)).
|
||||
- **`✗ gbrain failed — Executable not found in $PATH`** — the registered
|
||||
command was the bare `"gbrain"` name and opencode's PATH doesn't carry it.
|
||||
Use the absolute binary path in the user-global config, or fix PATH.
|
||||
- **Registered but nothing changed mid-session** — opencode reads config at
|
||||
session start; restart opencode (or start a new session) after registering.
|
||||
- **`OPENCODE_CONFIG` seems ignored** — observed inert in v1.18.18: only
|
||||
`HOME`/`XDG_CONFIG_HOME` move the config location. Don't rely on it.
|
||||
- **Which config won?** — `opencode debug config` prints the resolved merge;
|
||||
`opencode debug paths` prints every directory opencode uses.
|
||||
- **Rules files** — opencode loads the project `AGENTS.md` (a sibling
|
||||
`CLAUDE.md` is NOT double-loaded; AGENTS.md wins). gbrain's rendered
|
||||
workspace contract rides this natively.
|
||||
|
||||
---
|
||||
|
||||
Verified against **opencode v1.18.18** (fast-moving project — the pin is
|
||||
enforced in CI, with a latest-version canary leg watching for drift).
|
||||
Dev-facing observed-behavior notes (exact flag semantics, exit-code caveats,
|
||||
config schema, CI pin values) live in [OPENCODE-CLI-PIN.md](OPENCODE-CLI-PIN.md).
|
||||
@@ -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".
|
||||
```
|
||||
@@ -23,7 +23,7 @@ gbrain config set spend.posture gated # default — gates enforce
|
||||
| Value | Effect |
|
||||
|-------|--------|
|
||||
| `gated` (default) | Every cost gate enforces its limit as documented below. |
|
||||
| `tokenmax` | Every embedding-spend gate in the table below prints its estimate and **proceeds** — informational only. Spend is still recorded to the ledger; posture removes the *ceiling*, not the *accounting*. (Commands with their own LLM cost caps outside this doc's embedding scope — e.g. `extract-conversation-facts --max-cost-usd` — don't resolve posture; their per-call flags govern.) |
|
||||
| `tokenmax` | Every embedding-spend gate in the table below prints its estimate and **proceeds** — informational only. Spend is still recorded to the ledger; posture removes the *ceiling*, not the *accounting*. (Commands with their own LLM cost caps outside this doc's embedding scope — e.g. `extract-conversation-facts --max-cost-usd`, `dream retriage --max-usd` (an estimate-based soft stop) — don't resolve posture; their per-call flags govern.) |
|
||||
|
||||
`spend.posture` is deliberately separate from `search.mode=tokenmax` (which governs
|
||||
retrieval payload size, not embedding spend). When a gate fires and
|
||||
|
||||
@@ -28,6 +28,7 @@ Any of these commands stream events when `--progress-json` is set:
|
||||
- `gbrain eval`
|
||||
- `gbrain eval brainbench`
|
||||
- `gbrain apply-migrations` (the orchestrator + every child command)
|
||||
- `gbrain transcripts ingest` (per-file ticks + a per-session heartbeat over the import set)
|
||||
|
||||
Non-bulk commands (`stats`, `graph-query`, `get`, `put`, etc.) don't emit
|
||||
events — they return in under a second.
|
||||
@@ -135,6 +136,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 5–20s, 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`
|
||||
@@ -154,6 +159,9 @@ Stable phase names shipped in v0.15.2:
|
||||
fixture count and a percentage would lie
|
||||
- `export.pages`
|
||||
- `files.sync`
|
||||
- `transcripts.ingest` (one tick per session-log file; sessions inside a
|
||||
multi-session file — the hermes store, consumer exports — don't get their
|
||||
own ticks, so total = file count; each session emits a heartbeat instead)
|
||||
|
||||
Sub-phases exposed via `child()`:
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -64,6 +64,16 @@ If `claude` is not found: install Claude Code first, or use a block below.
|
||||
codex mcp add gbrain -- gbrain serve --surface verbs
|
||||
```
|
||||
|
||||
**Grok Build** (verify with `grok mcp doctor gbrain` — the add is lazy)
|
||||
```bash
|
||||
grok mcp add gbrain -e "GBRAIN_HOME=$HOME" -- gbrain serve --surface verbs
|
||||
```
|
||||
|
||||
**opencode** (verify with `opencode mcp list` — the add is lazy, and list SPAWNS the server)
|
||||
```bash
|
||||
opencode mcp add gbrain --env GBRAIN_HOME=$HOME -- gbrain serve --surface verbs
|
||||
```
|
||||
|
||||
**OpenClaw / any stdio MCP host** — register the server command
|
||||
`gbrain serve --surface verbs`. Remote brains: `gbrain serve --http` on the
|
||||
host, then `gbrain connect https://host/mcp --token gbrain_xxx --install` on
|
||||
@@ -71,10 +81,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 +211,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
|
||||
|
||||
@@ -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).)
|
||||
|
||||
|
||||
+218
-28
@@ -225,7 +225,11 @@ Per-file detail is in `docs/architecture/KEY_FILES.md`.
|
||||
- **Engine-live paths avoid runtime dynamic `import()` for helper dependencies.** In
|
||||
`src/core/pglite-engine.ts`, `src/core/postgres-engine.ts`, and
|
||||
`src/core/migrate.ts`, dependencies previously reached through runtime dynamic
|
||||
imports use static top-level imports. The only current dynamic-`import()` exceptions
|
||||
imports use static top-level imports. Besides the snapshot loader's lazy
|
||||
`require()` cluster in `pglite-engine.ts:tryLoadSnapshot` (fs/crypto/
|
||||
migrate/pglite-schema + one gateway shape lookup — lazy so production
|
||||
builds without the test-fixture path don't eager-load; the guard now
|
||||
matches `require()` calls too), the only dynamic-`import()` exceptions
|
||||
are the four `ai/gateway.ts` lookups in both engines'
|
||||
`initSchema()` and `_upsertChunksOnce()` methods; each remains lazy inside a
|
||||
local `try/catch` because the gateway has a large provider/config closure and,
|
||||
@@ -661,7 +665,7 @@ 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.8.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. |
|
||||
|
||||
@@ -1106,9 +1110,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:**
|
||||
|
||||
@@ -1232,6 +1237,44 @@ 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).
|
||||
|
||||
**If you are Grok Build** (xAI's `grok` CLI): register gbrain as your MCP server:
|
||||
|
||||
```bash
|
||||
grok mcp add gbrain -e "GBRAIN_HOME=$HOME" -- gbrain serve --surface verbs
|
||||
```
|
||||
|
||||
The add is lazy (exit 0 without connecting) — verify with
|
||||
`grok mcp doctor gbrain`, which spawns the server and must report
|
||||
`7 tools discovered`. This is the brain-only install; the `gbrain bootstrap`
|
||||
personal-agent path does not support Grok yet (Claude Code, Codex, and opencode only).
|
||||
Verified against Grok Build v1.0.4. Full reference:
|
||||
[docs/mcp/GROK.md](docs/mcp/GROK.md).
|
||||
|
||||
**If you are opencode** (the SST terminal agent, opencode.ai — not OpenClaw):
|
||||
you are a bootstrap-supported harness — for the full persistent-personal-agent
|
||||
install, follow `BOOTSTRAP_FOR_AGENTS.md` instead of this page. For the
|
||||
brain-only MCP registration:
|
||||
|
||||
```bash
|
||||
opencode mcp add gbrain --env GBRAIN_HOME=$HOME -- gbrain serve --surface verbs
|
||||
```
|
||||
|
||||
The add is lazy (exit 0 without connecting) — verify with `opencode mcp list`,
|
||||
which spawns the server and must show `✓ gbrain connected` (the exit code is 0
|
||||
even on failure; read the output). Restart opencode afterwards — it reads
|
||||
config at session start. Verified against opencode v1.18.18. Full reference:
|
||||
[docs/mcp/OPENCODE.md](docs/mcp/OPENCODE.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
|
||||
@@ -1510,11 +1553,12 @@ 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) |
|
||||
| "Run dream", "process today's session", "synthesize my conversations", "consolidate yesterday's conversations", "what patterns did you see", "did the dream cycle run" | `skills/maintain/SKILL.md` (dream cycle section) |
|
||||
| "Run dream", "process today's session", "synthesize my conversations", "consolidate yesterday's conversations", "what patterns did you see", "did the dream cycle run", "retriage the backlog", "re-score the triage" | `skills/maintain/SKILL.md` (dream cycle section) |
|
||||
| "Brain health", "what features am I missing", "brain score" | Run `gbrain features --json` |
|
||||
| "Set up autopilot", "run brain maintenance", "keep brain updated" | Run `gbrain autopilot --install --repo ~/brain` |
|
||||
| "Upgrade gbrain", "update gbrain", "gbrain update available", `UPGRADE_AVAILABLE`, "is gbrain up to date" | `skills/gbrain-upgrade/SKILL.md` |
|
||||
@@ -1596,7 +1640,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).
|
||||
|
||||
@@ -1671,7 +1715,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).
|
||||
|
||||
@@ -1688,7 +1734,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**: 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. 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, cloud sandboxes, 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.
|
||||
|
||||
@@ -1751,6 +1797,10 @@ 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).
|
||||
- **[Grok Build](docs/mcp/GROK.md)** — `grok mcp add gbrain -e "GBRAIN_HOME=$HOME" -- gbrain serve --surface verbs`. The add is lazy (exit 0 without connecting) — verify with `grok mcp doctor gbrain`, which spawns the server and reports `7 tools discovered`. Verified against Grok Build v1.0.4.
|
||||
- **[opencode](docs/mcp/OPENCODE.md)** (opencode.ai / SST — not OpenClaw) — `opencode mcp add gbrain --env GBRAIN_HOME=$HOME -- gbrain serve --surface verbs`, or let `gbrain bootstrap hooks --harness opencode` write the config for you (opencode is a bootstrap-supported harness — it reads AGENTS.md natively). The add is lazy — verify with `opencode mcp list`, which spawns the server (`✓ gbrain connected`). Remote: `gbrain connect https://your-host/mcp --token gbrain_xxx --agent opencode [--install]` — the config stores only the `{env:GBRAIN_REMOTE_TOKEN}` interpolation. Verified against opencode v1.18.18.
|
||||
- **[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.
|
||||
@@ -1811,6 +1861,21 @@ curl -X POST https://your-brain/ingest \
|
||||
For mobile capture, the inbox folder source picks up anything dropped into
|
||||
`~/.gbrain/inbox/` from iOS Shortcuts / AirDrop / Drafts / Finder.
|
||||
|
||||
Your other agents' histories import in one command. `gbrain transcripts ingest`
|
||||
parses agent session logs (Claude Code, Codex, OpenClaw, Hermes) and extracted
|
||||
consumer chat exports (ChatGPT / Claude.ai `conversations.json`) into readable
|
||||
conversation pages with provenance back to the exact session file. Secrets are
|
||||
scrubbed from message bodies, titles, speakers, and session metadata before
|
||||
anything is written, embedding is off by default for bulk backfills, and
|
||||
re-runs are free — unchanged sessions skip on content hash:
|
||||
|
||||
```bash
|
||||
gbrain transcripts ingest # discover importable session logs
|
||||
gbrain transcripts ingest --all # import everything discovered
|
||||
gbrain transcripts ingest ~/Downloads/conversations.json # consumer export (unzip first)
|
||||
gbrain transcripts status # found vs imported, per harness
|
||||
```
|
||||
|
||||
Third-party skillpacks can ship custom ingestion sources (Granola, Linear,
|
||||
voice, OCR) against the versioned `IngestionSource` contract at
|
||||
`gbrain/ingestion`. See [`docs/skillpack-anatomy.md`](docs/skillpack-anatomy.md).
|
||||
@@ -1872,7 +1937,7 @@ The whole loop is described in [`docs/architecture/topologies.md`](docs/architec
|
||||
|
||||
**Self-wiring knowledge graph.** Every `put_page` extracts entity refs from markdown/wikilinks/typed-link syntax and writes edges with zero LLM calls. Typed edges (`attended`, `works_at`, `invested_in`, `founded`, `advises`, `mentions`, …). Multi-hop traversal via `gbrain graph-query`. The graph is what produces the +31.4 P@5 lift over vector-only RAG. **Obsidian-style vaults:** bare `[[note-name]]` wikilinks that point across folders — you wrote `[[struktura]]` but the page lives at `projects/struktura.md` — resolve by basename once you opt in with `gbrain config set link_resolution.global_basename true`. Off by default; `gbrain doctor` tells you how many edges you'd gain before you flip it. See [migrating an Obsidian vault](INSTALL_FOR_AGENTS.md#step-45-wire-the-knowledge-graph).
|
||||
|
||||
**Job queue (Minions).** BullMQ-shaped, Postgres-native job queue. Durable subagents (LLM tool loops that survive crashes via two-phase pending→done persistence), shell jobs with audit, child jobs with cascading timeouts, rate leases for outbound providers, attachments via S3/Supabase storage. Replaces "spawn subagent as fire-and-forget Promise" with something that recovers from anything.
|
||||
**Job queue (Minions).** BullMQ-shaped, Postgres-native job queue. Durable subagents (LLM tool loops that survive crashes via two-phase pending→done persistence), shell jobs with audit, child jobs with cascading timeouts, rate leases for outbound providers, attachments via S3/Supabase storage. Opt-in per-job process isolation (`gbrain jobs work --job-isolation process`) runs each claimed job in its own SIGKILL-able child process, so a stuck handler dies for real and a crash takes one job instead of the whole worker; when the worker's DB health probe fails, it names the failing layer (`pool_starved` vs `server_unreachable`) instead of a blanket "DB unreachable". Sizing and rollout guidance in [`docs/guides/minions-deployment.md`](docs/guides/minions-deployment.md); probe-verdict triage in [`docs/guides/queue-operations-runbook.md`](docs/guides/queue-operations-runbook.md). Replaces "spawn subagent as fire-and-forget Promise" with something that recovers from anything.
|
||||
|
||||
**Non-English brains (FTS language config).** The Postgres full-text search tokenizer is configurable via `GBRAIN_FTS_LANGUAGE`. Defaults to `english`. Set it to any text-search configuration that exists in your Postgres instance:
|
||||
|
||||
@@ -2043,7 +2108,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
|
||||
@@ -3185,6 +3250,49 @@ it nightly and Phase 4 below (plus most of Phase 2's hygiene checks) is
|
||||
covered. The pseudocode that follows is the harness-side variant for agents
|
||||
that also do LLM-driven entity sweeps and memory consolidation on top.
|
||||
|
||||
### Synthesis cost control: the triage cascade
|
||||
|
||||
The synthesize phase is a two-stage cascade: a cheap scored triage
|
||||
(utility-tier model, one call per new transcript) gates the expensive
|
||||
per-transcript synthesis subagents. The dials:
|
||||
|
||||
- `dream.triage.threshold` (default 0.5) — the gate. Scores are cached, so
|
||||
retuning it re-gates instantly with **zero** new LLM calls. Raise it if too
|
||||
much routine content synthesizes; lower it if real signal is being skipped.
|
||||
- `models.dream.triage` — the triage model (default: utility tier / Haiku).
|
||||
- `dream.triage.max_chars` (default 24000, floor 1000) — per-transcript
|
||||
sample window (head/middle/tail) sent to the judge. Not part of cache
|
||||
validity — after changing it, `gbrain dream retriage --force` re-judges
|
||||
under the new sampling.
|
||||
- `dream.triage.max_tokens` (default 2048, floor 256) — judge output budget.
|
||||
- `dream.triage.concurrency` (default 4, clamped 1–16) — concurrent judge
|
||||
calls.
|
||||
- `dream.synthesize.max_turns` (default 16) — synthesis turn budget. The
|
||||
triage map hands the subagent pre-extracted segments, so the mid-tier
|
||||
default model (`models.dream.synthesize`, tier `reasoning`) with a 16-turn
|
||||
budget is the intended pairing — frontier-model overrides are unnecessary
|
||||
and slow the queue. Completeness comes from triage coverage (every file
|
||||
scored, minus files deferred under the `max_ms` budget below) plus
|
||||
segment-guided prompts, not model size. If written-page counts
|
||||
drop after upgrading, set it back to 30 and check
|
||||
`details.synthesis.avg_turns` for cap pressure.
|
||||
- `dream.triage.max_ms` (default 5 min) — per-cycle wall-clock budget for
|
||||
judging NEW files; a big cold corpus triages across a few cycles (cached
|
||||
files are free). Deferred files are labeled "not yet triaged", never
|
||||
silently rejected.
|
||||
- `dream.synthesize.max_submissions_per_source_per_day` (default 0 = off) —
|
||||
opt-in backstop cap on synthesis jobs per source; 200/day is a sane value
|
||||
for busy deployments.
|
||||
|
||||
Maintenance recipe — after changing the threshold, upgrading through a
|
||||
`TRIAGE_VERSION` bump, or to drain a queued synthesis backlog:
|
||||
|
||||
```bash
|
||||
gbrain dream retriage --dry-run # what would change (zero LLM calls)
|
||||
gbrain dream retriage --reconcile-queue # re-score + cancel below-threshold queued jobs
|
||||
gbrain dream retriage --audit-rejects 20 # synthesis-model second opinion on 20 rejects
|
||||
```
|
||||
|
||||
### What It Does
|
||||
|
||||
```
|
||||
@@ -3851,7 +3959,7 @@ The push channels share one zero-LLM core (`src/core/context/volunteer.ts`):
|
||||
| `reflex` | automatic, inside the context engine | default-on for plugin hosts; nothing to call |
|
||||
| `op` | `gbrain volunteer-context` / MCP `volunteer_context` | agents without the plugin; one call per turn |
|
||||
| `watch` | `gbrain watch` | stream a transcript in, volunteered pages stream out |
|
||||
| `claude-code` / `codex` | `gbrain hook user-prompt` (registered by `gbrain bootstrap`) | per-prompt injection inside a harness; see "Harness hooks" below |
|
||||
| `claude-code` / `codex` / `opencode` | `gbrain hook user-prompt` (registered by `gbrain bootstrap`) | per-prompt injection inside a harness; see "Harness hooks" below |
|
||||
|
||||
## How it decides
|
||||
|
||||
@@ -3913,7 +4021,7 @@ this channel production-grade rather than spammy-and-invisible:
|
||||
- **The feedback loop.** The serve logs each DELIVERED block's volunteered
|
||||
pages and pointers to `context_volunteer_events` under the hook's channel
|
||||
(`claude-code` by default; a codex hook registration passes
|
||||
`--harness codex`). `gbrain volunteer-context --stats` then shows
|
||||
`--harness codex` / `--harness opencode`). `gbrain volunteer-context --stats` then shows
|
||||
per-harness precision, and `gbrain doctor`'s `volunteer_channels` check
|
||||
shows which channels actually fire, with guidance for the two quiet cases:
|
||||
"hook installed but never registered (restart the session)" and "registered
|
||||
@@ -3966,9 +4074,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:
|
||||
@@ -3986,9 +4096,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)
|
||||
@@ -4029,8 +4140,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
|
||||
|
||||
@@ -4125,6 +4237,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`.
|
||||
@@ -4168,7 +4296,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
|
||||
|
||||
@@ -4194,8 +4323,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
|
||||
|
||||
@@ -4376,6 +4508,16 @@ If `claude` is not found: install Claude Code first, or use a block below.
|
||||
codex mcp add gbrain -- gbrain serve --surface verbs
|
||||
```
|
||||
|
||||
**Grok Build** (verify with `grok mcp doctor gbrain` — the add is lazy)
|
||||
```bash
|
||||
grok mcp add gbrain -e "GBRAIN_HOME=$HOME" -- gbrain serve --surface verbs
|
||||
```
|
||||
|
||||
**opencode** (verify with `opencode mcp list` — the add is lazy, and list SPAWNS the server)
|
||||
```bash
|
||||
opencode mcp add gbrain --env GBRAIN_HOME=$HOME -- gbrain serve --surface verbs
|
||||
```
|
||||
|
||||
**OpenClaw / any stdio MCP host** — register the server command
|
||||
`gbrain serve --surface verbs`. Remote brains: `gbrain serve --http` on the
|
||||
host, then `gbrain connect https://host/mcp --token gbrain_xxx --install` on
|
||||
@@ -4383,10 +4525,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
|
||||
|
||||
@@ -4495,6 +4655,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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "gbrain-context-engine",
|
||||
"name": "gbrain",
|
||||
"version": "0.45.9.0",
|
||||
"version": "0.46.4.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",
|
||||
|
||||
+9
-3
@@ -50,7 +50,9 @@
|
||||
"check:admin-scope-drift": "bash scripts/check-admin-scope-drift.sh",
|
||||
"check:cli-exec": "bash scripts/check-cli-executable.sh",
|
||||
"check:engine-dynamic-import": "bash scripts/check-engine-dynamic-import.sh",
|
||||
"check:all": "bash scripts/check-privacy.sh && bash scripts/check-proposal-pii.sh && bash scripts/check-test-real-names.sh && bash scripts/check-jsonb-pattern.sh && bash scripts/check-source-id-projection.sh && bash scripts/check-source-config-leak.sh && bash scripts/check-progress-to-stdout.sh && bash scripts/check-no-tracked-symlinks.sh && bash scripts/check-no-legacy-getconnection.sh && bash scripts/check-test-isolation.sh && bash scripts/check-trailing-newline.sh && bash scripts/check-wasm-embedded.sh && bash scripts/check-pglite-embedded.sh && bash scripts/check-exports-count.sh && bash scripts/check-admin-build.sh && bash scripts/check-admin-scope-drift.sh && bash scripts/check-cli-executable.sh && bash scripts/check-skill-brain-first.sh && bash scripts/check-operations-filter-bypass.sh && bash scripts/check-gateway-routed-no-direct-anthropic.sh && bash scripts/check-worker-pool-atomicity.sh && bash scripts/check-key-files-current-state.sh && bash scripts/check-no-double-retry.sh && bash scripts/check-batch-audit-site.sh && bash scripts/check-engine-dynamic-import.sh",
|
||||
"check:grok-pin": "bash scripts/check-grok-pin.sh",
|
||||
"check:opencode-pin": "bash scripts/check-opencode-pin.sh",
|
||||
"check:pin-doc-privacy": "bash scripts/check-pin-doc-privacy.sh",
|
||||
"check:gateway-routed": "bash scripts/check-gateway-routed-no-direct-anthropic.sh",
|
||||
"check:worker-pool-atomicity": "bash scripts/check-worker-pool-atomicity.sh",
|
||||
"check:doc-history": "bash scripts/check-key-files-current-state.sh",
|
||||
@@ -79,6 +81,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",
|
||||
@@ -96,7 +99,9 @@
|
||||
"prepublish:clawhub": "bun run build:all",
|
||||
"publish:clawhub": "clawhub package publish . --family bundle-plugin",
|
||||
"check:skill-refs": "bun scripts/check-skill-refs.mjs",
|
||||
"gate:skills": "bash scripts/skills-commit-gate.sh"
|
||||
"gate:skills": "bash scripts/skills-commit-gate.sh",
|
||||
"check:guard-self-test": "bash scripts/guard-self-test.sh",
|
||||
"check:no-legacy-getconnection": "bash scripts/check-no-legacy-getconnection.sh"
|
||||
},
|
||||
"openclaw": {
|
||||
"compat": {
|
||||
@@ -129,6 +134,7 @@
|
||||
"gray-matter": "^4.0.3",
|
||||
"heic-decode": "^2.1.0",
|
||||
"js-yaml": "^3.15.1",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"marked": "^18.0.2",
|
||||
"openai": "^4.0.0",
|
||||
"pgvector": "^0.2.0",
|
||||
@@ -154,7 +160,7 @@
|
||||
"bun": ">=1.3.10"
|
||||
},
|
||||
"license": "MIT",
|
||||
"version": "0.45.9.0",
|
||||
"version": "0.46.4.0",
|
||||
"overrides": {
|
||||
"@hono/node-server": "^2.0.5",
|
||||
"fast-uri": "^3.1.5",
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
#!/usr/bin/env bun
|
||||
// scripts/build-pglite-snapshot.ts
|
||||
//
|
||||
// TZ pinned to UTC BEFORE any PGLite work: dumpDataDir bakes this process's
|
||||
// TimeZone into the tar's cluster defaults. Building under the host zone made
|
||||
// restored engines run sessions in the build machine's zone (the engine also
|
||||
// re-pins at restore — this is the belt to that suspender, and it keeps any
|
||||
// OTHER zone-derived state baked into the tar deterministic across hosts).
|
||||
process.env.TZ = 'UTC';
|
||||
//
|
||||
// Tier 3 fast-restore: boot a fresh PGLite, run the full initSchema (forward
|
||||
// bootstrap + PGLITE_SCHEMA_SQL + every migration), dump the post-init state
|
||||
// to a tar fixture. Test files that read GBRAIN_PGLITE_SNAPSHOT can skip the
|
||||
@@ -18,10 +25,14 @@
|
||||
//
|
||||
// Re-run whenever you touch src/core/migrate.ts or src/schema.sql.
|
||||
|
||||
import { writeFileSync, mkdirSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import { writeFileSync, mkdirSync, existsSync, readFileSync, rmdirSync, rmSync, mkdtempSync, statSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import * as crypto from "node:crypto";
|
||||
|
||||
import { configureGateway, getEmbeddingDimensions, getEmbeddingModel } from "../src/core/ai/gateway.ts";
|
||||
import { LEGACY_EMBEDDING_CONFIG } from "../test/helpers/legacy-embedding-config.ts";
|
||||
|
||||
import { PGLiteEngine, computeSnapshotSchemaHash } from "../src/core/pglite-engine.ts";
|
||||
import { MIGRATIONS } from "../src/core/migrate.ts";
|
||||
import { PGLITE_SCHEMA_SQL } from "../src/core/pglite-schema.ts";
|
||||
@@ -33,9 +44,84 @@ function computeSchemaHash(): string {
|
||||
async function main() {
|
||||
const fixturePath = "test/fixtures/pglite-snapshot.tar";
|
||||
const versionPath = "test/fixtures/pglite-snapshot.version";
|
||||
const lockPath = "test/fixtures/.pglite-snapshot.lock";
|
||||
mkdirSync(dirname(fixturePath), { recursive: true });
|
||||
|
||||
// W0 fix-wave: build under the EXACT embedding shape the test suite pins.
|
||||
// bunfig.toml preloads test/helpers/legacy-embedding-preload.ts, which
|
||||
// configures the gateway to the shared LEGACY_EMBEDDING_CONFIG (OpenAI
|
||||
// 1536-d) for every `bun test` file — so the snapshot's baked vector(dims)
|
||||
// columns MUST match that shape, not the builder machine's ambient config
|
||||
// (nor the shipped 1280-d default an unconfigured gateway falls back to).
|
||||
// Set in main(), not module scope: ESM hoists imports, so module-scope
|
||||
// placement implied an ordering it never had — config reads are lazy.
|
||||
configureGateway({ ...LEGACY_EMBEDDING_CONFIG, env: { ...process.env } });
|
||||
|
||||
const schemaHash = computeSchemaHash();
|
||||
|
||||
// W0 fix-wave (Tier-1 #16): idempotent short-circuit. Runners now call this
|
||||
// script UNCONDITIONALLY (build-if-missing left stale-but-present snapshots
|
||||
// permanently on the warn+slow path); a fresh snapshot exits in ~ms.
|
||||
const isFresh = () => {
|
||||
if (!existsSync(fixturePath) || !existsSync(versionPath)) return false;
|
||||
const lines = readFileSync(versionPath, "utf-8").trim().split("\n");
|
||||
return lines[0] === schemaHash
|
||||
&& lines[1] === `dims=${getEmbeddingDimensions()}`
|
||||
&& lines[2] === `model=${getEmbeddingModel()}`;
|
||||
};
|
||||
if (isFresh()) {
|
||||
console.log(`[build-pglite-snapshot] up to date (hash ${schemaHash.slice(0, 16)}...) — nothing to do`);
|
||||
return;
|
||||
}
|
||||
|
||||
// GBRAIN_HOME isolation is only needed once we actually BUILD (the engine
|
||||
// boot reads config). Red-team catch: creating it before the isFresh()
|
||||
// short-circuit leaked one temp dir per invocation on the COMMON path
|
||||
// (this script runs on every `bun run test`).
|
||||
const hermeticHome = mkdtempSync(join(tmpdir(), "gbrain-snapshot-hermetic-"));
|
||||
process.env.GBRAIN_HOME = hermeticHome;
|
||||
|
||||
// W0 fix-wave (D5.8): concurrency lock. Parallel shard runners / concurrent
|
||||
// Conductor workspaces invoking this simultaneously must not tear the tar.
|
||||
// mkdir is atomic; the loser polls until the winner finishes, then
|
||||
// re-checks freshness and exits.
|
||||
let ownLock = false;
|
||||
try {
|
||||
mkdirSync(lockPath);
|
||||
ownLock = true;
|
||||
} catch {
|
||||
console.log(`[build-pglite-snapshot] another builder holds ${lockPath}; waiting...`);
|
||||
const timeoutMs = Number(process.env.GBRAIN_SNAPSHOT_LOCK_TIMEOUT_MS) || 120_000;
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (existsSync(lockPath) && Date.now() < deadline) {
|
||||
await new Promise(r => setTimeout(r, 250));
|
||||
}
|
||||
if (isFresh()) {
|
||||
console.log(`[build-pglite-snapshot] concurrent builder finished; snapshot fresh`);
|
||||
return;
|
||||
}
|
||||
// Stale lock (crashed builder) or still-stale snapshot: TAKE OVER.
|
||||
// W0 ship-review catch: mkdirSync on a still-existing dir always throws
|
||||
// EEXIST — the original retry could never acquire, so a single crashed
|
||||
// builder left every future rebuild waiting the full deadline and then
|
||||
// proceeding UNLOCKED forever (the stale dir was never removed).
|
||||
// Red-team refinement: verify STALENESS (lock dir mtime older than the
|
||||
// full wait window) before the rmdir — two exhausted waiters would
|
||||
// otherwise each rmdir+mkdir and the second would steal the first's
|
||||
// just-created LIVE lock, re-opening the torn-tar window.
|
||||
try {
|
||||
if (existsSync(lockPath)) {
|
||||
const ageMs = Date.now() - statSync(lockPath).mtimeMs;
|
||||
if (ageMs > timeoutMs) {
|
||||
console.log(`[build-pglite-snapshot] stale lock (age ${Math.round(ageMs / 1000)}s > ${Math.round(timeoutMs / 1000)}s) — taking over`);
|
||||
rmdirSync(lockPath);
|
||||
}
|
||||
}
|
||||
mkdirSync(lockPath);
|
||||
ownLock = true;
|
||||
} catch { /* lock is LIVE (fresh mtime) or takeover raced; proceed unlocked as last resort */ }
|
||||
}
|
||||
try {
|
||||
console.log(`[build-pglite-snapshot] schema hash: ${schemaHash.slice(0, 16)}...`);
|
||||
console.log(`[build-pglite-snapshot] booting PGLite (in-memory)...`);
|
||||
const engine = new PGLiteEngine();
|
||||
@@ -53,12 +139,21 @@ async function main() {
|
||||
const dump = await engine.db.dumpDataDir("none");
|
||||
const buffer = Buffer.from(await dump.arrayBuffer());
|
||||
|
||||
// Write tar first, version LAST — the version file is the commit point, so
|
||||
// a crash between the writes leaves a stale-hash (ignored) snapshot, never
|
||||
// a fresh-looking torn one. Lines 2-3 record the embedding shape the
|
||||
// snapshot was baked with; the loader refuses a shape-mismatched snapshot
|
||||
// (the W0 1280-vs-1536 incident class).
|
||||
writeFileSync(fixturePath, buffer);
|
||||
writeFileSync(versionPath, schemaHash + "\n");
|
||||
writeFileSync(versionPath, `${schemaHash}\ndims=${getEmbeddingDimensions()}\nmodel=${getEmbeddingModel()}\n`);
|
||||
await engine.disconnect();
|
||||
|
||||
console.log(`[build-pglite-snapshot] wrote ${fixturePath} (${buffer.length} bytes)`);
|
||||
console.log(`[build-pglite-snapshot] wrote ${versionPath}`);
|
||||
} finally {
|
||||
if (ownLock) { try { rmdirSync(lockPath); } catch { /* best effort */ } }
|
||||
try { rmSync(hermeticHome, { recursive: true, force: true }); } catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
await main();
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
# scopes to admin/src/ so we can't import the source list directly; instead
|
||||
# this script extracts both lists and diffs them.
|
||||
#
|
||||
# Wired into `bun run verify` and `bun run check:all`.
|
||||
# Wired into `bun run verify` (single guard registry: scripts/guards-manifest.tsv).
|
||||
#
|
||||
# Exits 0 on match, 1 on drift, 2 on internal error (file missing, parse fail).
|
||||
#
|
||||
|
||||
@@ -25,14 +25,16 @@
|
||||
# (d) Phase-list check [D5]: every `Phase: <name>` in BOOTSTRAP_FOR_AGENTS.md
|
||||
# must appear in src/core/bootstrap/status.ts (the TS phase list is the
|
||||
# single source; the runbook defers to it). Skips while either is absent.
|
||||
# (e) Harness-scoping counter-signal pins: the MCP-scope consent is Claude
|
||||
# Code only (Codex has no scope flag — `codex mcp add` is user-global).
|
||||
# Tripwires against accidental deletion of the load-bearing prose, not
|
||||
# proofs of placement: the runbook must carry the Codex bullet's
|
||||
# "Do NOT offer an MCP scope choice" and the phase-3 "Claude Code only"
|
||||
# scoping; questions.json's MCP_SCOPE.question must START WITH
|
||||
# "(Claude Code only". Intentional rewording updates these pins in the
|
||||
# same commit. Skips while the runbook/bank are absent.
|
||||
# (e) Harness-scoping counter-signal pins: the MCP-scope consent applies on
|
||||
# Claude Code and opencode (Codex has no scope flag — `codex mcp add` is
|
||||
# user-global; opencode DEFAULTS to user-global — no trust gate on
|
||||
# project-config servers). Tripwires against accidental deletion of the
|
||||
# load-bearing prose, not proofs of placement: the runbook must carry the
|
||||
# Codex bullet's "Do NOT offer an MCP scope choice" and the phase-3
|
||||
# "Claude Code and opencode" scoping; questions.json's MCP_SCOPE.question
|
||||
# must START WITH "(Claude Code and opencode". Intentional rewording
|
||||
# updates these pins in the same commit. Skips while the runbook/bank are
|
||||
# absent.
|
||||
#
|
||||
# BSD/GNU grep portable (no \t escapes). Uses `bun` for JSON parsing — the
|
||||
# check runs via `bun run verify`, so bun is always present.
|
||||
@@ -195,7 +197,7 @@ else
|
||||
echo "SKIP: phase-list check (runbook or src/core/bootstrap/status.ts absent)"
|
||||
fi
|
||||
|
||||
# ── (e) harness-scoping counter-signal pins (MCP scope is Claude Code only) ─
|
||||
# ── (e) harness-scoping counter-signal pins (scope = Claude Code + opencode) ─
|
||||
if [ -f "$RUNBOOK" ]; then
|
||||
if ! grep -qF 'Do NOT offer an MCP scope choice' "$RUNBOOK"; then
|
||||
fail=1
|
||||
@@ -204,11 +206,20 @@ if [ -f "$RUNBOOK" ]; then
|
||||
echo " without this line, Codex-door agents re-ask a dead question." >&2
|
||||
echo " Rewording intentionally? Update this pin in the same commit." >&2
|
||||
fi
|
||||
if ! grep -qF 'Claude Code only' "$RUNBOOK"; then
|
||||
if ! grep -qF 'Claude Code and opencode' "$RUNBOOK"; then
|
||||
fail=1
|
||||
echo "FAIL: BOOTSTRAP_FOR_AGENTS.md lost the 'Claude Code only' scoping on the" >&2
|
||||
echo " MCP-scope consent (phase 3). Without it the consent reads as" >&2
|
||||
echo " harness-blind and Codex-door agents ask it." >&2
|
||||
echo "FAIL: BOOTSTRAP_FOR_AGENTS.md lost the 'Claude Code and opencode' scoping" >&2
|
||||
echo " on the MCP-scope consent (phase 3). Without it the consent reads as" >&2
|
||||
echo " harness-blind: Codex-door agents ask a dead question and opencode" >&2
|
||||
echo " agents miss the inverted (user-global) default." >&2
|
||||
echo " Rewording intentionally? Update this pin in the same commit." >&2
|
||||
fi
|
||||
if ! grep -qF 'NO trust prompt' "$RUNBOOK"; then
|
||||
fail=1
|
||||
echo "FAIL: BOOTSTRAP_FOR_AGENTS.md lost the opencode spawn-gate rationale" >&2
|
||||
echo " ('NO trust prompt'). Without it agents recommend the Claude-style" >&2
|
||||
echo " project default on opencode — where a committed project entry" >&2
|
||||
echo " auto-executes on every collaborator machine." >&2
|
||||
echo " Rewording intentionally? Update this pin in the same commit." >&2
|
||||
fi
|
||||
else
|
||||
@@ -216,9 +227,9 @@ else
|
||||
fi
|
||||
if [ -f "$QUESTIONS" ] && command -v bun >/dev/null 2>&1; then
|
||||
if ! GBRAIN_QJSON="$QUESTIONS" bun -e \
|
||||
'const fs=require("fs");let b;try{b=JSON.parse(fs.readFileSync(process.env.GBRAIN_QJSON,"utf8"));}catch(e){process.exit(1);}if(!b.questions){process.exit(1);}const e=b.questions.MCP_SCOPE;const q=(e&&e.question)||"";process.exit(q.startsWith("(Claude Code only")&&e.phase==="interview"?0:1);'; then
|
||||
'const fs=require("fs");let b;try{b=JSON.parse(fs.readFileSync(process.env.GBRAIN_QJSON,"utf8"));}catch(e){process.exit(1);}if(!b.questions){process.exit(1);}const e=b.questions.MCP_SCOPE;const q=(e&&e.question)||"";process.exit(q.startsWith("(Claude Code and opencode")&&e.phase==="interview"?0:1);'; then
|
||||
fail=1
|
||||
echo "FAIL: questions.json MCP_SCOPE.question must start with '(Claude Code only'" >&2
|
||||
echo "FAIL: questions.json MCP_SCOPE.question must start with '(Claude Code and opencode'" >&2
|
||||
echo " AND MCP_SCOPE.phase must be 'interview' (the consent is recorded" >&2
|
||||
echo " pre-confirm during the interview; a 'wire' phase re-creates the" >&2
|
||||
echo " bank-vs-runbook contradiction). Also fails when the questions" >&2
|
||||
|
||||
@@ -49,7 +49,16 @@ for (const file of files) {
|
||||
}
|
||||
|
||||
function visit(node: ts.Node): void {
|
||||
if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword) {
|
||||
// W0 ship-review catch: match BOTH lazy-loading forms. The guard
|
||||
// previously matched only `import(...)` call expressions, so a
|
||||
// `require(...)` on an engine-live path passed silently and its
|
||||
// engine-dynamic-import-ok marker was decorative.
|
||||
const isDynamicImport = ts.isCallExpression(node)
|
||||
&& node.expression.kind === ts.SyntaxKind.ImportKeyword;
|
||||
const isRequireCall = ts.isCallExpression(node)
|
||||
&& ts.isIdentifier(node.expression)
|
||||
&& node.expression.text === 'require';
|
||||
if (isDynamicImport || isRequireCall) {
|
||||
const { line } = sourceFile.getLineAndCharacterOfPosition(node.expression.getStart(sourceFile));
|
||||
const sourceLine = lines[line] ?? '';
|
||||
if (!markerLines.has(line)) {
|
||||
|
||||
@@ -21,10 +21,16 @@
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
FIXTURE_DIR="test/fixtures/conversation-formats"
|
||||
# cathedral-4: the transcripts-import fixtures (raw harness/export shapes)
|
||||
# carry the same placeholder-names-only contract as conversation-formats.
|
||||
FIXTURE_DIRS=("test/fixtures/conversation-formats" "test/fixtures/transcripts")
|
||||
|
||||
if [ ! -d "$FIXTURE_DIR" ]; then
|
||||
echo "[check-fixture-privacy] $FIXTURE_DIR does not exist; nothing to check"
|
||||
EXISTING_DIRS=()
|
||||
for d in "${FIXTURE_DIRS[@]}"; do
|
||||
[ -d "$d" ] && EXISTING_DIRS+=("$d")
|
||||
done
|
||||
if [ ${#EXISTING_DIRS[@]} -eq 0 ]; then
|
||||
echo "[check-fixture-privacy] no fixture dirs exist; nothing to check"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -45,7 +51,7 @@ BANNED_TOKENS=(
|
||||
|
||||
errors=0
|
||||
for token in "${BANNED_TOKENS[@]}"; do
|
||||
matches=$(grep -ril "$token" "$FIXTURE_DIR" 2>/dev/null || true)
|
||||
matches=$(grep -ril "$token" "${EXISTING_DIRS[@]}" 2>/dev/null || true)
|
||||
if [ -n "$matches" ]; then
|
||||
echo "[check-fixture-privacy] BANNED token '$token' found in:"
|
||||
echo "$matches" | sed 's/^/ - /'
|
||||
@@ -61,4 +67,4 @@ if [ "$errors" -gt 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[check-fixture-privacy] OK: no banned tokens found in $FIXTURE_DIR"
|
||||
echo "[check-fixture-privacy] OK: no banned tokens found in ${EXISTING_DIRS[*]}"
|
||||
|
||||
Executable
+125
@@ -0,0 +1,125 @@
|
||||
#!/usr/bin/env bash
|
||||
# scripts/check-grok-pin.sh — grok pin consistency guard.
|
||||
#
|
||||
# GROK-CLI-PIN.md is the single observed-behavior source for the grok
|
||||
# integration; its pins fan out to the heavy-tests grok-door job env, the
|
||||
# GrokRunner argv, and the door e2e assertions. The prose rule is "update
|
||||
# together" — this guard turns the workflow half of that rule into CI:
|
||||
#
|
||||
# 1. docs/mcp/GROK-CLI-PIN.md carries a machine-stable stamp block
|
||||
# (`<!-- grok-pin: key=value -->`, one per line) including
|
||||
# distribution_kind (npm | installer).
|
||||
# 2. The grok-door job env in .github/workflows/heavy-tests.yml must carry
|
||||
# EXACTLY the pin set for the chosen distribution_kind:
|
||||
# npm: GROK_VERSION==grok_version, GROK_NPM_PACKAGE==npm_package,
|
||||
# GROK_NPM_INTEGRITY==npm_integrity; no GROK_INSTALL_SHA256.
|
||||
# installer: GROK_VERSION==grok_version,
|
||||
# GROK_INSTALL_SHA256==installer_sha256; no GROK_NPM_INTEGRITY.
|
||||
# (The pin DOC may document both — the fallback path stays written down;
|
||||
# exclusivity is about which pins the WORKFLOW actually enforces.)
|
||||
#
|
||||
# Greps are anchored to the grok-door job block so a future canary matrix leg
|
||||
# (or a second door job) cannot satisfy the check by accident.
|
||||
#
|
||||
# SKIP-GRACEFUL: missing pin doc, missing workflow, or no grok-door job yet →
|
||||
# SKIP (exit 0), matching scripts/check-bootstrap-tag.sh. Test override:
|
||||
# GBRAIN_GROK_PIN_GUARD_ROOT points file resolution at a fixture tree.
|
||||
# BSD/GNU portable (no \t escapes, no GNU-only flags).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="${GBRAIN_GROK_PIN_GUARD_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}"
|
||||
PIN_FILE="$ROOT/docs/mcp/GROK-CLI-PIN.md"
|
||||
WORKFLOW="$ROOT/.github/workflows/heavy-tests.yml"
|
||||
|
||||
if [ ! -f "$WORKFLOW" ]; then
|
||||
echo "check-grok-pin: SKIP (no $WORKFLOW)"
|
||||
exit 0
|
||||
fi
|
||||
if ! grep -q '^ grok-door:' "$WORKFLOW"; then
|
||||
echo "check-grok-pin: SKIP (no grok-door job in heavy-tests.yml yet)"
|
||||
exit 0
|
||||
fi
|
||||
# Once the grok-door job EXISTS, a missing pin doc is a FAILURE, not a skip —
|
||||
# deleting/renaming the doc must not silently disable the supply-chain gate.
|
||||
if [ ! -f "$PIN_FILE" ]; then
|
||||
echo "check-grok-pin: FAIL — grok-door job exists but $PIN_FILE is missing (the pin doc is the gate's source of truth)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
fail() {
|
||||
echo "check-grok-pin: FAIL — $1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- 1. Parse the stamp block ------------------------------------------------
|
||||
stamp() {
|
||||
# First occurrence wins; a missing stamp yields the empty string (callers
|
||||
# decide whether that is a failure) — the `|| true` keeps set -e/pipefail
|
||||
# from treating grep's no-match exit as a script error.
|
||||
{ grep -E "^<!-- grok-pin: $1=" "$PIN_FILE" || true; } | head -1 \
|
||||
| sed -e 's/^<!-- grok-pin: [a-z0-9_]*=//' -e 's/ -->$//'
|
||||
}
|
||||
|
||||
# Duplicate stamps are drift bait (two values, which one is real?).
|
||||
dupes=$({ grep -E '^<!-- grok-pin: ' "$PIN_FILE" || true; } | sed -e 's/^<!-- grok-pin: //' -e 's/=.*$//' | sort | uniq -d)
|
||||
[ -n "$dupes" ] && fail "duplicate grok-pin stamp(s) in GROK-CLI-PIN.md: $dupes"
|
||||
|
||||
DIST_KIND=$(stamp distribution_kind)
|
||||
GROK_VERSION_PIN=$(stamp grok_version)
|
||||
[ -n "$DIST_KIND" ] || fail "GROK-CLI-PIN.md is missing the distribution_kind stamp"
|
||||
[ -n "$GROK_VERSION_PIN" ] || fail "GROK-CLI-PIN.md is missing the grok_version stamp"
|
||||
case "$DIST_KIND" in
|
||||
npm|installer) ;;
|
||||
*) fail "distribution_kind stamp must be npm or installer; got '$DIST_KIND'" ;;
|
||||
esac
|
||||
|
||||
# --- 2. Extract the grok-door job block --------------------------------------
|
||||
# Jobs sit at 2-space indent; the block ends at the next 2-space-indented key.
|
||||
job_block=$(awk '
|
||||
/^ grok-door:/ { f = 1; print; next }
|
||||
f && /^ [A-Za-z0-9_-]+:/ { exit }
|
||||
f { print }
|
||||
' "$WORKFLOW")
|
||||
[ -n "$job_block" ] || fail "could not extract the grok-door job block"
|
||||
|
||||
wf_env() {
|
||||
# Strip either quote style: a YAML-formatter pass flipping double to single
|
||||
# quotes must not read as pin drift.
|
||||
{ printf '%s\n' "$job_block" | grep -E "^ $1:" || true; } | head -1 \
|
||||
| sed -e "s/^ $1:[[:space:]]*//" -e 's/^"//' -e 's/"$//' -e "s/^'//" -e "s/'\$//"
|
||||
}
|
||||
|
||||
WF_VERSION=$(wf_env GROK_VERSION)
|
||||
WF_NPM_PACKAGE=$(wf_env GROK_NPM_PACKAGE)
|
||||
WF_NPM_INTEGRITY=$(wf_env GROK_NPM_INTEGRITY)
|
||||
WF_INSTALL_SHA=$(wf_env GROK_INSTALL_SHA256)
|
||||
|
||||
[ -n "$WF_VERSION" ] || fail "grok-door job env is missing GROK_VERSION"
|
||||
[ "$WF_VERSION" = "$GROK_VERSION_PIN" ] || fail "GROK_VERSION drift — workflow '$WF_VERSION' vs pin-doc stamp '$GROK_VERSION_PIN' (update together; see the pin doc's re-observation checklist)"
|
||||
|
||||
if [ "$DIST_KIND" = "npm" ]; then
|
||||
NPM_PACKAGE_PIN=$(stamp npm_package)
|
||||
NPM_INTEGRITY_PIN=$(stamp npm_integrity)
|
||||
[ -n "$NPM_PACKAGE_PIN" ] || fail "distribution_kind=npm but GROK-CLI-PIN.md is missing the npm_package stamp"
|
||||
[ -n "$NPM_INTEGRITY_PIN" ] || fail "distribution_kind=npm but GROK-CLI-PIN.md is missing the npm_integrity stamp"
|
||||
[ -n "$WF_NPM_PACKAGE" ] || fail "distribution_kind=npm but the grok-door job env is missing GROK_NPM_PACKAGE"
|
||||
[ -n "$WF_NPM_INTEGRITY" ] || fail "distribution_kind=npm but the grok-door job env is missing GROK_NPM_INTEGRITY"
|
||||
[ "$WF_NPM_PACKAGE" = "$NPM_PACKAGE_PIN" ] || fail "GROK_NPM_PACKAGE drift — workflow '$WF_NPM_PACKAGE' vs stamp '$NPM_PACKAGE_PIN'"
|
||||
[ "$WF_NPM_INTEGRITY" = "$NPM_INTEGRITY_PIN" ] || fail "GROK_NPM_INTEGRITY drift — workflow vs stamp mismatch"
|
||||
# npm_version is a documented near-duplicate of grok_version — assert they
|
||||
# agree so bumping one alone can never pass green.
|
||||
NPM_VERSION_PIN=$(stamp npm_version)
|
||||
if [ -n "$NPM_VERSION_PIN" ] && [ "$NPM_VERSION_PIN" != "$GROK_VERSION_PIN" ]; then
|
||||
fail "npm_version stamp ($NPM_VERSION_PIN) disagrees with grok_version stamp ($GROK_VERSION_PIN) — update together"
|
||||
fi
|
||||
[ -z "$WF_INSTALL_SHA" ] || fail "distribution_kind=npm but the grok-door job also pins GROK_INSTALL_SHA256 — one provisioning mode only (mode exclusivity)"
|
||||
else
|
||||
INSTALL_SHA_PIN=$(stamp installer_sha256)
|
||||
[ -n "$INSTALL_SHA_PIN" ] || fail "distribution_kind=installer but GROK-CLI-PIN.md is missing the installer_sha256 stamp"
|
||||
[ -n "$WF_INSTALL_SHA" ] || fail "distribution_kind=installer but the grok-door job env is missing GROK_INSTALL_SHA256"
|
||||
[ "$WF_INSTALL_SHA" = "$INSTALL_SHA_PIN" ] || fail "GROK_INSTALL_SHA256 drift — workflow vs stamp mismatch"
|
||||
[ -z "$WF_NPM_INTEGRITY" ] || fail "distribution_kind=installer but the grok-door job also pins GROK_NPM_INTEGRITY — one provisioning mode only (mode exclusivity)"
|
||||
fi
|
||||
|
||||
echo "check-grok-pin: ok ($DIST_KIND mode, grok $GROK_VERSION_PIN)"
|
||||
@@ -17,26 +17,43 @@ set -euo pipefail
|
||||
ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
# Match the interpolated form: ${JSON.stringify(...)}::jsonb
|
||||
# Using grep -P for Perl-compatible regex (lookahead-free pattern is enough here).
|
||||
PATTERN='\$\{JSON\.stringify\([^)]*\)\}::jsonb'
|
||||
# W0 fix-wave (Tier-1 #11): self-test seam — the guard harness points this at
|
||||
# a known-bad fixture tree and asserts exit 1.
|
||||
SCAN_ROOT="${GBRAIN_GUARD_ROOT:-src/}"
|
||||
|
||||
if grep -rEn "$PATTERN" src/ 2>/dev/null; then
|
||||
# Match the interpolated form: ${JSON.stringify(...)}::jsonb
|
||||
#
|
||||
# W0 fix-wave (Tier-1 #11): the previous `\([^)]*\)` argument matcher could
|
||||
# not cross a nested `)` — `${JSON.stringify(obj.get())}::jsonb` was
|
||||
# invisible (the same regex-hole class that made check-no-double-retry a
|
||||
# permanently-green no-op). `[^}]*` spans nested parens but CANNOT cross the
|
||||
# interpolation's closing `}`, so a safe `${JSON.stringify(x)}::text::jsonb`
|
||||
# followed by a separate `${expr()}::jsonb` on the same line is not spanned
|
||||
# into a false positive (ship-review catch — the greedy `.*` variant was).
|
||||
PATTERN='\$\{JSON\.stringify\([^}]*\)\}::jsonb'
|
||||
|
||||
if grep -rEn "$PATTERN" "$SCAN_ROOT" 2>/dev/null; then
|
||||
echo
|
||||
echo "ERROR: Found JSON.stringify(...)::jsonb pattern in src/."
|
||||
echo "ERROR: Found JSON.stringify(...)::jsonb pattern in $SCAN_ROOT."
|
||||
echo " postgres.js v3 stringifies again, producing JSONB string literals."
|
||||
echo " Use sql.json(x) instead. See feedback_postgres_jsonb_double_encode.md."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "OK: no JSON.stringify(x)::jsonb interpolation pattern in src/"
|
||||
echo "OK: no JSON.stringify(x)::jsonb interpolation pattern in $SCAN_ROOT"
|
||||
|
||||
# v0.13.1 #219: guard against max_stalled DEFAULT 1 regressing in any schema
|
||||
# source file. DEFAULT 1 dead-lettered any SIGKILL'd job on first stall, making
|
||||
# the "10/10 rescued" claim false for out-of-the-box users. Default is 5 now.
|
||||
MAX_STALLED_PATTERN='max_stalled\s+INTEGER\s+NOT\s+NULL\s+DEFAULT\s+1\b'
|
||||
|
||||
if grep -rEn "$MAX_STALLED_PATTERN" src/schema.sql src/core/migrate.ts src/core/pglite-schema.ts src/core/schema-embedded.ts 2>/dev/null; then
|
||||
# Schema files are fixed paths; under a fixture root (self-test) they don't
|
||||
# exist — skip rather than fail on the missing-file grep.
|
||||
SCHEMA_FILES=()
|
||||
for f in src/schema.sql src/core/migrate.ts src/core/pglite-schema.ts src/core/schema-embedded.ts; do
|
||||
[ -f "$f" ] && SCHEMA_FILES+=("$f")
|
||||
done
|
||||
if [ "${#SCHEMA_FILES[@]}" -gt 0 ] && grep -rEn "$MAX_STALLED_PATTERN" "${SCHEMA_FILES[@]}" 2>/dev/null; then
|
||||
echo
|
||||
echo "ERROR: max_stalled DEFAULT 1 reintroduced in schema."
|
||||
echo " Must be DEFAULT 5 to preserve SIGKILL-rescue guarantee. See #219."
|
||||
@@ -51,10 +68,11 @@ echo "OK: max_stalled defaults are 5 in all schema sources"
|
||||
# [JSON.stringify(x)]) — which is the exact shape that double-encoded the
|
||||
# op_checkpoints pin and aborted every sync in #2339. The AST-lite scanner below
|
||||
# catches it. `set -e` propagates its non-zero exit.
|
||||
# Under a fixture root, scan that root; the AST-lite scanner takes roots as argv.
|
||||
if command -v node >/dev/null 2>&1; then
|
||||
node scripts/check-jsonb-params.mjs
|
||||
node scripts/check-jsonb-params.mjs ${GBRAIN_GUARD_ROOT:+"$GBRAIN_GUARD_ROOT"}
|
||||
elif command -v bun >/dev/null 2>&1; then
|
||||
bun scripts/check-jsonb-params.mjs
|
||||
bun scripts/check-jsonb-params.mjs ${GBRAIN_GUARD_ROOT:+"$GBRAIN_GUARD_ROOT"}
|
||||
else
|
||||
echo "WARN: neither node nor bun on PATH; skipping check-jsonb-params.mjs" >&2
|
||||
fi
|
||||
|
||||
@@ -19,16 +19,24 @@ set -euo pipefail
|
||||
ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
# W0 fix-wave (Tier-1 #11): self-test seam. The guard harness points this at
|
||||
# a known-bad fixture tree and asserts exit 1 — the guard can no longer rot
|
||||
# into a permanently-green no-op unnoticed.
|
||||
SCAN_ROOT="${GBRAIN_GUARD_ROOT:-src/}"
|
||||
|
||||
# Match: withRetry(...) wrapping any of the 3 engine batch methods.
|
||||
# The greedy `.*` between `withRetry(` and `engine.` covers both the
|
||||
# arrow-fn form and any direct invocation. (gbrain-allow-direct-insert: doc comment)
|
||||
# Multi-line wraps are caught by `grep -E` per file (line-wise) for the
|
||||
# common single-line case; multi-line wraps still get caught by a separate
|
||||
# multi-line pass below.
|
||||
PATTERN='withRetry\([^)]*engine\.(addLinksBatch|addTimelineEntriesBatch|upsertChunks)'
|
||||
#
|
||||
# W0 fix-wave (Tier-1 #11): the previous pattern used `[^)]*` between
|
||||
# `withRetry(` and `engine.`, which can never cross the `)` in `() =>` — so
|
||||
# the CANONICAL banned shape (an arrow function wrapping the engine batch
|
||||
# call) was invisible and the guard had been permanently green since it
|
||||
# shipped. `.*` (line-bounded by grep) covers the arrow form, async arrows,
|
||||
# and any argument shape. (Spelled without the literal call token here —
|
||||
# check-system-of-record scans scripts/ comments too: the prose-bleed class.)
|
||||
PATTERN='withRetry\(.*engine\.(addLinksBatch|addTimelineEntriesBatch|upsertChunks)'
|
||||
|
||||
# Single-line scan (covers ~95% of real cases).
|
||||
if grep -rEn "$PATTERN" src/ --include='*.ts' 2>/dev/null; then
|
||||
if grep -rEn "$PATTERN" "$SCAN_ROOT" --include='*.ts' 2>/dev/null; then
|
||||
echo
|
||||
echo "ERROR: Found withRetry(...engine.{addLinksBatch|addTimelineEntriesBatch|upsertChunks})"
|
||||
echo " pattern in src/."
|
||||
@@ -47,17 +55,26 @@ if grep -rEn "$PATTERN" src/ --include='*.ts' 2>/dev/null; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Multi-line scan: a withRetry( on one line and the engine call on the next
|
||||
# few. Bounded to 3-line window so we don't flag distant unrelated calls.
|
||||
# Uses pcregrep if available, else falls back to a simple awk window.
|
||||
if command -v pcregrep >/dev/null 2>&1; then
|
||||
if pcregrep -r -M -n --include='\.ts$' \
|
||||
'withRetry\([^)]*\n\s*\(?[^)]*=>\s*engine\.(addLinksBatch|addTimelineEntriesBatch|upsertChunks)' \
|
||||
src/ 2>/dev/null; then
|
||||
echo
|
||||
echo "ERROR: Multi-line withRetry(...engine.batch...) wrap found in src/. See above."
|
||||
exit 1
|
||||
fi
|
||||
# Multi-line scan: a withRetry( on one line and the engine call within the
|
||||
# next 3 lines. W0 fix-wave (Tier-1 #11): the previous pass was gated on
|
||||
# pcregrep, which is not installed on dev machines OR CI — it never ran.
|
||||
# perl is always available; same 3-line window, always on.
|
||||
#
|
||||
# Ship-review catch: perl must ALWAYS exit 0 and let OUTPUT PRESENCE decide.
|
||||
# An exit-1-from-clean-batches design breaks under `set -o pipefail` the
|
||||
# moment src/ outgrows one xargs batch (xargs exits 123, overriding grep's
|
||||
# verdict) — a silently missed violation, the same permanently-green class
|
||||
# this guard was just cured of.
|
||||
MULTILINE_MATCHES=$(find "$SCAN_ROOT" -name '*.ts' -type f -print0 2>/dev/null | xargs -0 perl -0777 -ne '
|
||||
if (/withRetry\([^\n]*\n(?:[^\n]*\n){0,2}?[^\n]*engine\.(?:addLinksBatch|addTimelineEntriesBatch|upsertChunks)/) {
|
||||
print "$ARGV: multi-line withRetry wrap around an engine batch call\n";
|
||||
}
|
||||
' 2>/dev/null || true)
|
||||
if [ -n "$MULTILINE_MATCHES" ]; then
|
||||
echo "$MULTILINE_MATCHES"
|
||||
echo
|
||||
echo "ERROR: Multi-line withRetry(...engine.batch...) wrap found in $SCAN_ROOT. See above."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "OK: no withRetry(...engine.batch...) double-retry patterns in src/"
|
||||
echo "OK: no withRetry(...engine.batch...) double-retry patterns in $SCAN_ROOT"
|
||||
|
||||
Executable
+149
@@ -0,0 +1,149 @@
|
||||
#!/usr/bin/env bash
|
||||
# scripts/check-opencode-pin.sh — opencode pin consistency guard.
|
||||
#
|
||||
# OPENCODE-CLI-PIN.md is the single observed-behavior source for the opencode
|
||||
# integration; its pins fan out to the heavy-tests opencode-door job env, the
|
||||
# OpencodeRunner argv, and the door e2e assertions. The prose rule is "update
|
||||
# together" — this guard turns the workflow half of that rule into CI:
|
||||
#
|
||||
# 1. docs/mcp/OPENCODE-CLI-PIN.md carries a machine-stable stamp block
|
||||
# (`<!-- opencode-pin: key=value -->`, one per line) including
|
||||
# distribution_kind (npm | installer).
|
||||
# 2. The opencode-door job env in .github/workflows/heavy-tests.yml must carry
|
||||
# EXACTLY the pin set for the chosen distribution_kind:
|
||||
# npm: OPENCODE_VERSION==opencode_version, OPENCODE_NPM_PACKAGE==npm_package,
|
||||
# OPENCODE_NPM_INTEGRITY==npm_integrity; no OPENCODE_INSTALL_SHA256.
|
||||
# installer: OPENCODE_VERSION==opencode_version,
|
||||
# OPENCODE_INSTALL_SHA256==installer_sha256; no OPENCODE_NPM_INTEGRITY.
|
||||
# (The pin DOC may document both — the fallback path stays written down;
|
||||
# exclusivity is about which pins the WORKFLOW actually enforces.)
|
||||
#
|
||||
# Greps are anchored to the opencode-door job block so a future canary matrix leg
|
||||
# (or a second door job) cannot satisfy the check by accident.
|
||||
#
|
||||
# SKIP-GRACEFUL: missing pin doc, missing workflow, or no opencode-door job yet →
|
||||
# SKIP (exit 0), matching scripts/check-bootstrap-tag.sh. Test override:
|
||||
# GBRAIN_OPENCODE_PIN_GUARD_ROOT points file resolution at a fixture tree.
|
||||
# BSD/GNU portable (no \t escapes, no GNU-only flags).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="${GBRAIN_OPENCODE_PIN_GUARD_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}"
|
||||
PIN_FILE="$ROOT/docs/mcp/OPENCODE-CLI-PIN.md"
|
||||
WORKFLOW="$ROOT/.github/workflows/heavy-tests.yml"
|
||||
|
||||
if [ ! -f "$WORKFLOW" ]; then
|
||||
echo "check-opencode-pin: SKIP (no $WORKFLOW)"
|
||||
exit 0
|
||||
fi
|
||||
if ! grep -q '^ opencode-door:' "$WORKFLOW"; then
|
||||
echo "check-opencode-pin: SKIP (no opencode-door job in heavy-tests.yml yet)"
|
||||
exit 0
|
||||
fi
|
||||
# Once the opencode-door job EXISTS, a missing pin doc is a FAILURE, not a skip —
|
||||
# deleting/renaming the doc must not silently disable the supply-chain gate.
|
||||
if [ ! -f "$PIN_FILE" ]; then
|
||||
echo "check-opencode-pin: FAIL — opencode-door job exists but $PIN_FILE is missing (the pin doc is the gate's source of truth)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
fail() {
|
||||
echo "check-opencode-pin: FAIL — $1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- 1. Parse the stamp block ------------------------------------------------
|
||||
stamp() {
|
||||
# First occurrence wins; a missing stamp yields the empty string (callers
|
||||
# decide whether that is a failure) — the `|| true` keeps set -e/pipefail
|
||||
# from treating grep's no-match exit as a script error.
|
||||
{ grep -E "^<!-- opencode-pin: $1=" "$PIN_FILE" || true; } | head -1 \
|
||||
| sed -e 's/^<!-- opencode-pin: [a-z0-9_]*=//' -e 's/ -->$//'
|
||||
}
|
||||
|
||||
# Duplicate stamps are drift bait (two values, which one is real?).
|
||||
dupes=$({ grep -E '^<!-- opencode-pin: ' "$PIN_FILE" || true; } | sed -e 's/^<!-- opencode-pin: //' -e 's/=.*$//' | sort | uniq -d)
|
||||
[ -n "$dupes" ] && fail "duplicate opencode-pin stamp(s) in OPENCODE-CLI-PIN.md: $dupes"
|
||||
|
||||
DIST_KIND=$(stamp distribution_kind)
|
||||
OPENCODE_VERSION_PIN=$(stamp opencode_version)
|
||||
[ -n "$DIST_KIND" ] || fail "OPENCODE-CLI-PIN.md is missing the distribution_kind stamp"
|
||||
[ -n "$OPENCODE_VERSION_PIN" ] || fail "OPENCODE-CLI-PIN.md is missing the opencode_version stamp"
|
||||
case "$DIST_KIND" in
|
||||
npm|installer) ;;
|
||||
*) fail "distribution_kind stamp must be npm or installer; got '$DIST_KIND'" ;;
|
||||
esac
|
||||
|
||||
# --- 2. Extract the opencode-door job block --------------------------------------
|
||||
# Jobs sit at 2-space indent; the block ends at the next 2-space-indented key.
|
||||
job_block=$(awk '
|
||||
/^ opencode-door:/ { f = 1; print; next }
|
||||
f && /^ [A-Za-z0-9_-]+:/ { exit }
|
||||
f { print }
|
||||
' "$WORKFLOW")
|
||||
[ -n "$job_block" ] || fail "could not extract the opencode-door job block"
|
||||
|
||||
wf_env() {
|
||||
# Strip either quote style: a YAML-formatter pass flipping double to single
|
||||
# quotes must not read as pin drift.
|
||||
{ printf '%s\n' "$job_block" | grep -E "^ $1:" || true; } | head -1 \
|
||||
| sed -e "s/^ $1:[[:space:]]*//" -e 's/^"//' -e 's/"$//' -e "s/^'//" -e "s/'\$//"
|
||||
}
|
||||
|
||||
WF_VERSION=$(wf_env OPENCODE_VERSION)
|
||||
WF_NPM_PACKAGE=$(wf_env OPENCODE_NPM_PACKAGE)
|
||||
WF_NPM_INTEGRITY=$(wf_env OPENCODE_NPM_INTEGRITY)
|
||||
WF_INSTALL_SHA=$(wf_env OPENCODE_INSTALL_SHA256)
|
||||
|
||||
[ -n "$WF_VERSION" ] || fail "opencode-door job env is missing OPENCODE_VERSION"
|
||||
[ "$WF_VERSION" = "$OPENCODE_VERSION_PIN" ] || fail "OPENCODE_VERSION drift — workflow '$WF_VERSION' vs pin-doc stamp '$OPENCODE_VERSION_PIN' (update together; see the pin doc's re-observation checklist)"
|
||||
|
||||
# EVERY OPENCODE_VERSION: env line in the WHOLE workflow (the real-agent-e2e
|
||||
# door job carries a second copy) must equal the stamp — bumping the door job
|
||||
# alone must never pass green. Env keys sit at line start after indentation,
|
||||
# so comments mentioning the name never match.
|
||||
all_wf_versions=$({ grep -E '^[[:space:]]*OPENCODE_VERSION:' "$WORKFLOW" || true; } \
|
||||
| sed -e 's/^[[:space:]]*OPENCODE_VERSION:[[:space:]]*//' -e 's/^"//' -e 's/"$//' -e "s/^'//" -e "s/'\$//")
|
||||
for v in $all_wf_versions; do
|
||||
[ "$v" = "$OPENCODE_VERSION_PIN" ] || fail "an OPENCODE_VERSION occurrence elsewhere in heavy-tests.yml ('$v') disagrees with the pin-doc stamp '$OPENCODE_VERSION_PIN' — every copy in the workflow moves with the stamp"
|
||||
done
|
||||
|
||||
if [ "$DIST_KIND" = "npm" ]; then
|
||||
NPM_PACKAGE_PIN=$(stamp npm_package)
|
||||
NPM_INTEGRITY_PIN=$(stamp npm_integrity)
|
||||
[ -n "$NPM_PACKAGE_PIN" ] || fail "distribution_kind=npm but OPENCODE-CLI-PIN.md is missing the npm_package stamp"
|
||||
[ -n "$NPM_INTEGRITY_PIN" ] || fail "distribution_kind=npm but OPENCODE-CLI-PIN.md is missing the npm_integrity stamp"
|
||||
[ -n "$WF_NPM_PACKAGE" ] || fail "distribution_kind=npm but the opencode-door job env is missing OPENCODE_NPM_PACKAGE"
|
||||
[ -n "$WF_NPM_INTEGRITY" ] || fail "distribution_kind=npm but the opencode-door job env is missing OPENCODE_NPM_INTEGRITY"
|
||||
[ "$WF_NPM_PACKAGE" = "$NPM_PACKAGE_PIN" ] || fail "OPENCODE_NPM_PACKAGE drift — workflow '$WF_NPM_PACKAGE' vs stamp '$NPM_PACKAGE_PIN'"
|
||||
[ "$WF_NPM_INTEGRITY" = "$NPM_INTEGRITY_PIN" ] || fail "OPENCODE_NPM_INTEGRITY drift — workflow vs stamp mismatch"
|
||||
# npm_version is a documented near-duplicate of opencode_version — assert they
|
||||
# agree so bumping one alone can never pass green.
|
||||
NPM_VERSION_PIN=$(stamp npm_version)
|
||||
if [ -n "$NPM_VERSION_PIN" ] && [ "$NPM_VERSION_PIN" != "$OPENCODE_VERSION_PIN" ]; then
|
||||
fail "npm_version stamp ($NPM_VERSION_PIN) disagrees with opencode_version stamp ($OPENCODE_VERSION_PIN) — update together"
|
||||
fi
|
||||
# Platform-payload integrity stamps (the door job byte-pins the linux
|
||||
# sub-packages too): when the pin doc carries them, the job env must match.
|
||||
X64_PIN=$(stamp npm_linux_x64_integrity)
|
||||
if [ -n "$X64_PIN" ]; then
|
||||
WF_X64=$(wf_env OPENCODE_NPM_LINUX_X64_INTEGRITY)
|
||||
[ -n "$WF_X64" ] || fail "pin doc stamps npm_linux_x64_integrity but the opencode-door job env is missing OPENCODE_NPM_LINUX_X64_INTEGRITY"
|
||||
[ "$WF_X64" = "$X64_PIN" ] || fail "OPENCODE_NPM_LINUX_X64_INTEGRITY drift — workflow vs stamp mismatch"
|
||||
fi
|
||||
ARM64_PIN=$(stamp npm_linux_arm64_integrity)
|
||||
if [ -n "$ARM64_PIN" ]; then
|
||||
WF_ARM64=$(wf_env OPENCODE_NPM_LINUX_ARM64_INTEGRITY)
|
||||
[ -n "$WF_ARM64" ] || fail "pin doc stamps npm_linux_arm64_integrity but the opencode-door job env is missing OPENCODE_NPM_LINUX_ARM64_INTEGRITY"
|
||||
[ "$WF_ARM64" = "$ARM64_PIN" ] || fail "OPENCODE_NPM_LINUX_ARM64_INTEGRITY drift — workflow vs stamp mismatch"
|
||||
fi
|
||||
[ -z "$WF_INSTALL_SHA" ] || fail "distribution_kind=npm but the opencode-door job also pins OPENCODE_INSTALL_SHA256 — one provisioning mode only (mode exclusivity)"
|
||||
else
|
||||
INSTALL_SHA_PIN=$(stamp installer_sha256)
|
||||
[ -n "$INSTALL_SHA_PIN" ] || fail "distribution_kind=installer but OPENCODE-CLI-PIN.md is missing the installer_sha256 stamp"
|
||||
[ -n "$WF_INSTALL_SHA" ] || fail "distribution_kind=installer but the opencode-door job env is missing OPENCODE_INSTALL_SHA256"
|
||||
[ "$WF_INSTALL_SHA" = "$INSTALL_SHA_PIN" ] || fail "OPENCODE_INSTALL_SHA256 drift — workflow vs stamp mismatch"
|
||||
[ -z "$WF_NPM_INTEGRITY" ] || fail "distribution_kind=installer but the opencode-door job also pins OPENCODE_NPM_INTEGRITY — one provisioning mode only (mode exclusivity)"
|
||||
fi
|
||||
|
||||
echo "check-opencode-pin: ok ($DIST_KIND mode, opencode $OPENCODE_VERSION_PIN)"
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
# - new logging APIs that may show up later (the regex matches the URL,
|
||||
# not the consumer; any leak will trip)
|
||||
#
|
||||
# Wired into bun run check:all and bun run verify.
|
||||
# Wired into bun run verify (single guard registry: scripts/guards-manifest.tsv).
|
||||
#
|
||||
# Exit codes: 0 = clean, 1 = found at least one suspect line.
|
||||
set -euo pipefail
|
||||
|
||||
Executable
+70
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env bash
|
||||
# scripts/check-pin-doc-privacy.sh — PIN-doc privacy guard.
|
||||
#
|
||||
# The docs/mcp/*-CLI-PIN.md files carry VERBATIM observation transcripts from
|
||||
# real installs (help output, saved configs, error copy). That verbatim
|
||||
# discipline is the point — but it is also exactly how an operator path
|
||||
# (/Users/<name>/…), a key fragment, or an account id ends up committed and
|
||||
# shipped with every release. This guard asserts the placeholder discipline:
|
||||
#
|
||||
# 1. No operator home paths: /Users/<name>/ or /home/<name>/ must appear as
|
||||
# placeholders (<tmp>, $HOME, ~/) — never as a real username path.
|
||||
# Bare `~/.grok`-style spellings are fine (that IS the placeholder).
|
||||
# 2. No key material: long high-entropy tokens with known prefixes
|
||||
# (sk-…, xai-…, gbrain_<64+hex-ish>, ANTHROPIC/OPENAI/XAI key shapes).
|
||||
# npm `sha512-…` integrity pins are EXPECTED content — excluded.
|
||||
# 3. No obvious account ids: emails outside example.com/invalid domains.
|
||||
#
|
||||
# SKIP-GRACEFUL: no pin docs yet → SKIP (exit 0). Test override:
|
||||
# GBRAIN_PIN_PRIVACY_GUARD_ROOT points file resolution at a fixture tree.
|
||||
# BSD/GNU portable.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
ROOT="${GBRAIN_PIN_PRIVACY_GUARD_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}"
|
||||
shopt -s nullglob
|
||||
PIN_DOCS=("$ROOT"/docs/mcp/*-CLI-PIN.md)
|
||||
shopt -u nullglob
|
||||
|
||||
if [ "${#PIN_DOCS[@]}" -eq 0 ]; then
|
||||
echo "check-pin-doc-privacy: SKIP (no docs/mcp/*-CLI-PIN.md yet)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
fail=0
|
||||
|
||||
for doc in "${PIN_DOCS[@]}"; do
|
||||
rel="${doc#"$ROOT"/}"
|
||||
|
||||
# 1. Operator home paths (a real username after /Users/ or /home/).
|
||||
hits=$(grep -nE '(/Users|/home)/[A-Za-z][A-Za-z0-9._-]+/' "$doc" || true)
|
||||
if [ -n "$hits" ]; then
|
||||
fail=1
|
||||
echo "FAIL: $rel carries operator home path(s) — replace with <tmp>/\$HOME/~ placeholders:" >&2
|
||||
printf '%s\n' "$hits" | sed 's/^/ /' >&2
|
||||
fi
|
||||
|
||||
# 2. Key material. sha512- npm integrity pins are expected; exclude lines
|
||||
# carrying them before scanning for long secret-shaped runs.
|
||||
hits=$(grep -v 'sha512-' "$doc" | grep -nE '(sk-[A-Za-z0-9_-]{20,}|xai-[A-Za-z0-9_-]{20,}|gbrain_[A-Za-z0-9]{32,}|AKIA[0-9A-Z]{16})' || true)
|
||||
if [ -n "$hits" ]; then
|
||||
fail=1
|
||||
echo "FAIL: $rel carries key-shaped material — redact before committing:" >&2
|
||||
printf '%s\n' "$hits" | sed 's/^/ /' >&2
|
||||
fi
|
||||
|
||||
# 3. Emails outside the documentation-safe domains.
|
||||
hits=$(grep -nE '[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}' "$doc" \
|
||||
| grep -vE '@(example\.(com|org|net)|[A-Za-z0-9.-]*invalid)' || true)
|
||||
if [ -n "$hits" ]; then
|
||||
fail=1
|
||||
echo "FAIL: $rel carries a non-placeholder email address:" >&2
|
||||
printf '%s\n' "$hits" | sed 's/^/ /' >&2
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$fail" -ne 0 ]; then
|
||||
echo "check-pin-doc-privacy: FAIL (pin docs ship with every release — placeholder discipline is the privacy IRON RULE)" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "check-pin-doc-privacy: ok (${#PIN_DOCS[@]} pin doc(s))"
|
||||
@@ -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.
|
||||
|
||||
Executable
+40
@@ -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"
|
||||
+6
-6
@@ -236,12 +236,12 @@ bash scripts/check-progress-to-stdout.sh
|
||||
bash scripts/check-trailing-newline.sh
|
||||
bash scripts/check-wasm-embedded.sh
|
||||
bun run typecheck
|
||||
echo \"[runner] Tier 3: building PGLite snapshot fixture (cached across reruns)\"
|
||||
if [ ! -f test/fixtures/pglite-snapshot.tar ] || [ ! -f test/fixtures/pglite-snapshot.version ]; then
|
||||
bun run build:pglite-snapshot
|
||||
else
|
||||
echo \"[runner] snapshot fixture exists; engine will validate hash at load time\"
|
||||
fi
|
||||
echo \"[runner] Tier 3: PGLite snapshot fixture (idempotent; rebuilds on hash drift)\"
|
||||
# W0 fix-wave (Tier-1 #16): unconditional call — the build script self-
|
||||
# short-circuits on a fresh hash and rebuilds STALE snapshots (the old
|
||||
# if-missing guard left a stale-but-present snapshot permanently on the
|
||||
# warn+slow path). Concurrency-safe via the script's mkdir lock (D5.8).
|
||||
bun run build:pglite-snapshot
|
||||
export GBRAIN_PGLITE_SNAPSHOT=test/fixtures/pglite-snapshot.tar
|
||||
echo \"[runner] resolving E2E file selection (--diff aware)\"
|
||||
${DIFF_E2E_PREP}
|
||||
|
||||
@@ -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);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
|
||||
@@ -156,6 +156,12 @@ export function renderRegistryModule(registry: Record<string, string[]>): string
|
||||
// Regenerate: bun run build:flag-registry
|
||||
// Freshness + drift pinned by test/cli-flag-validation.test.ts (#2185).
|
||||
//
|
||||
// Merge conflict here? Do not hand-merge it. This file is regenerated on most
|
||||
// upstream waves, so a branch that also regenerates it conflicts on the whole
|
||||
// body. Take the base branch's copy wholesale, then re-run the command above —
|
||||
// the freshness test named above fails loudly if that regeneration was done
|
||||
// against the wrong base.
|
||||
//
|
||||
// Per-command legal flags for CLI_ONLY commands, derived from each command's
|
||||
// source (case block + imported modules + one level of relative imports +
|
||||
// scripts/generate-flag-registry.ts EXTRA_FLAGS). Deliberately over-inclusive
|
||||
|
||||
@@ -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).`);
|
||||
Executable
+94
@@ -0,0 +1,94 @@
|
||||
#!/usr/bin/env bash
|
||||
# W0 fix-wave (Tier-1 #11 / D5.14): guard self-test harness.
|
||||
#
|
||||
# The audit found scripts/check-no-double-retry.sh had been PERMANENTLY GREEN
|
||||
# since it shipped: its regex could not match the canonical banned shape, and
|
||||
# its multi-line fallback was gated on pcregrep, which is installed nowhere.
|
||||
# A guard that cannot fail is worse than no guard — it reads as coverage.
|
||||
#
|
||||
# This harness makes that class structurally impossible for scanner guards:
|
||||
# every guard marked `selftest yes` in scripts/guards-manifest.tsv is run
|
||||
# against test/fixtures/guards/<guard>/bad (MUST exit non-zero) and
|
||||
# .../good (MUST exit 0), via the GBRAIN_GUARD_ROOT override each guard
|
||||
# honors. Adding a self-test to a `todo` scanner = flip the manifest flag +
|
||||
# drop two fixture files.
|
||||
#
|
||||
# Also prints total harness wall-clock (guard-runtime budget line, D4.5):
|
||||
# fails if the self-test pass exceeds the budget, so guard sprawl shows up
|
||||
# here before it shows up as slow `bun run verify`.
|
||||
#
|
||||
# Usage: scripts/guard-self-test.sh
|
||||
# Exit: 0 = every self-tested guard fails on bad + passes on good.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
MANIFEST="scripts/guards-manifest.tsv"
|
||||
FIXTURES="test/fixtures/guards"
|
||||
BUDGET_SECONDS=30
|
||||
START=$(date +%s)
|
||||
failures=0
|
||||
tested=0
|
||||
|
||||
if [ ! -f "$MANIFEST" ]; then
|
||||
echo "ERROR: $MANIFEST missing — the guard registry is load-bearing."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
run_guard() {
|
||||
local guard="$1" fixture_root="$2"
|
||||
case "$guard" in
|
||||
*.mjs) GBRAIN_GUARD_ROOT="$fixture_root" node "scripts/$guard" "$fixture_root" >/dev/null 2>&1 ;;
|
||||
*) GBRAIN_GUARD_ROOT="$fixture_root" bash "scripts/$guard" >/dev/null 2>&1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
while IFS=$'\t' read -r guard klass selftest _notes; do
|
||||
case "$guard" in ''|'#'*) continue ;; esac
|
||||
[ "$selftest" = "yes" ] || continue
|
||||
tested=$((tested + 1))
|
||||
|
||||
bad="$FIXTURES/$guard/bad"
|
||||
good="$FIXTURES/$guard/good"
|
||||
if [ ! -d "$bad" ] || [ ! -d "$good" ]; then
|
||||
echo "FAIL $guard: manifest says selftest=yes but fixtures missing under $FIXTURES/$guard/{bad,good}"
|
||||
failures=$((failures + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
if run_guard "$guard" "$bad"; then
|
||||
echo "FAIL $guard: did NOT flag the known-bad fixture — the guard is a no-op (the check-no-double-retry class)"
|
||||
failures=$((failures + 1))
|
||||
elif ! run_guard "$guard" "$good"; then
|
||||
echo "FAIL $guard: flagged the known-good fixture — false positive"
|
||||
failures=$((failures + 1))
|
||||
else
|
||||
echo "ok $guard (bad→fail, good→pass)"
|
||||
fi
|
||||
done < "$MANIFEST"
|
||||
|
||||
# Manifest completeness: every scripts/check-* guard must have a manifest row
|
||||
# (new guards can't silently skip classification).
|
||||
for f in scripts/check-*.sh scripts/check-*.mjs; do
|
||||
base="$(basename "$f")"
|
||||
# The .ts companion of check-engine-dynamic-import is an implementation file.
|
||||
if ! grep -q "^${base} " "$MANIFEST"; then
|
||||
echo "FAIL $base: no row in $MANIFEST — classify it (scanner|buildfresh|repostate)"
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
ELAPSED=$(( $(date +%s) - START ))
|
||||
echo "guard self-test: $tested guard(s) self-tested, ${ELAPSED}s (budget ${BUDGET_SECONDS}s)"
|
||||
if [ "$ELAPSED" -gt "$BUDGET_SECONDS" ]; then
|
||||
echo "FAIL guard self-test exceeded the ${BUDGET_SECONDS}s runtime budget — trim fixtures or parallelize before adding more"
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
|
||||
if [ "$failures" -gt 0 ]; then
|
||||
echo "ERROR: $failures guard self-test failure(s)."
|
||||
exit 1
|
||||
fi
|
||||
echo "OK: all self-tested guards catch their bad fixtures and pass their good ones"
|
||||
@@ -0,0 +1,65 @@
|
||||
# CI guard registry (W0 fix-wave, Tier-1 #11 / D5.14).
|
||||
# THE single registry of scripts/check-* guards. package.json's `check:all`
|
||||
# (a second, stale, hand-synced copy) was deleted; scripts/run-verify-parallel.sh
|
||||
# executes guards, and scripts/guard-self-test.sh consumes THIS file to
|
||||
# self-test every scanner guard against known-bad/known-good fixtures under
|
||||
# test/fixtures/guards/<guard>/{bad,good}/ (env: GBRAIN_GUARD_ROOT).
|
||||
#
|
||||
# class: scanner = greps/parses repo sources; MUST eventually carry fixtures
|
||||
# (selftest yes|todo). A scanner guard with selftest=todo is
|
||||
# tracked debt — the class that produced two permanently-
|
||||
# green guards (check-no-double-retry, pcregrep-gated pass).
|
||||
# buildfresh = runs builds/regenerators and diffs outputs; self-tests
|
||||
# don't apply (the build IS the test). exempt.
|
||||
# repostate = checks repo/file state (modes, symlinks, VERSION stamps);
|
||||
# exempt with reason.
|
||||
#
|
||||
# guard class selftest notes
|
||||
check-no-double-retry.sh scanner yes regex hole fixed in W0 (could not match `() =>`); perl multi-line pass replaces never-installed pcregrep
|
||||
check-jsonb-pattern.sh scanner yes nested-paren hole fixed in W0; safe ::text::jsonb spelling stays unflagged
|
||||
check-jsonb-params.mjs scanner yes positional $N::jsonb AST-lite scanner; argv/env root override
|
||||
check-batch-audit-site.sh scanner todo
|
||||
check-bun-test-timeout.sh scanner todo
|
||||
check-fixture-privacy.sh scanner todo
|
||||
check-no-legacy-getconnection.sh scanner todo was reachable from neither verify nor CI pre-W0 (check:all only)
|
||||
check-no-pii-in-agent-voice.sh scanner todo
|
||||
check-operations-filter-bypass.sh scanner todo
|
||||
check-pagetype-exhaustive.sh scanner todo
|
||||
check-pg-url-redaction.sh scanner todo
|
||||
check-privacy.sh scanner todo
|
||||
check-progress-to-stdout.sh scanner todo
|
||||
check-proposal-pii.sh scanner todo
|
||||
check-search-path.sh scanner todo
|
||||
check-skill-brain-first.sh scanner todo
|
||||
check-skill-refs.mjs scanner todo
|
||||
check-source-config-leak.sh scanner todo
|
||||
check-source-id-projection.sh scanner todo
|
||||
check-source-scope-onboard.sh scanner todo
|
||||
check-synthetic-corpus-privacy.sh scanner todo
|
||||
check-system-of-record.sh scanner todo
|
||||
check-test-real-names.sh scanner todo
|
||||
check-worker-lock-renewal-shape.sh scanner todo
|
||||
check-worker-pool-atomicity.sh scanner todo
|
||||
check-gateway-routed-no-direct-anthropic.sh scanner todo
|
||||
check-engine-dynamic-import.sh scanner todo .ts companion is its implementation, not a separate guard
|
||||
check-key-files-current-state.sh scanner todo
|
||||
check-exports-count.sh scanner todo was reachable from neither verify nor CI pre-W0 (check:all only)
|
||||
check-trailing-newline.sh scanner todo was reachable from neither verify nor CI pre-W0 (check:all only)
|
||||
check-test-isolation.sh scanner todo allowlist data file: check-test-isolation.allowlist
|
||||
check-admin-build.sh buildfresh exempt runs the admin build; the build is the test
|
||||
check-admin-embedded.sh buildfresh exempt embed freshness diff
|
||||
check-admin-scope-drift.sh buildfresh exempt regenerates + diffs
|
||||
check-bootstrap-templates.sh buildfresh exempt regenerates template tree + diffs
|
||||
check-eval-glossary-fresh.sh buildfresh exempt regenerates + diffs
|
||||
check-fuzz-purity.sh buildfresh exempt executes fuzz corpus
|
||||
check-image-decoders-embedded.sh buildfresh exempt binary embed check
|
||||
check-pglite-embedded.sh buildfresh exempt binary embed check
|
||||
check-skills-manifest-fresh.sh buildfresh exempt regenerates + diffs
|
||||
check-tool-catalog-fresh.sh buildfresh exempt regenerates + diffs
|
||||
check-wasm-embedded.sh buildfresh exempt binary embed check
|
||||
check-bootstrap-tag.sh repostate exempt VERSION stamp drift check
|
||||
check-cli-executable.sh repostate exempt file-mode check
|
||||
check-no-tracked-symlinks.sh repostate exempt git index state check
|
||||
check-grok-pin.sh repostate exempt pin-stamp drift check (GROK-CLI-PIN.md stamps vs heavy-tests grok-door env); own bun guard tests in test/check-bootstrap-guards.test.ts
|
||||
check-opencode-pin.sh repostate exempt pin-stamp drift check (OPENCODE-CLI-PIN.md stamps vs heavy-tests opencode-door env); own bun guard tests in test/check-bootstrap-guards.test.ts
|
||||
check-pin-doc-privacy.sh repostate exempt PIN-doc placeholder discipline (no operator paths/key material/emails in docs/mcp/*-CLI-PIN.md); own bun guard tests in test/check-bootstrap-guards.test.ts
|
||||
|
+33
-10
@@ -36,6 +36,16 @@ set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
# #3485: this wrapper IS the e2e boundary — opt in to running with a database
|
||||
# URL present. The bunfig test preload (database-url-guard-preload.ts) refuses
|
||||
# bare `bun test` runs while DATABASE_URL/GBRAIN_DATABASE_URL is ambient; the
|
||||
# per-file name floor (test/helpers/db-guard.ts) still applies after this.
|
||||
export GBRAIN_TEST_ALLOW_DATABASE_URL=1
|
||||
# The e2e suite runs on DATABASE_URL only; an ambient GBRAIN_DATABASE_URL
|
||||
# would pass the opt-in yet reach CLI-subprocess paths with no name floor —
|
||||
# drop it here so only the floored variable crosses the boundary.
|
||||
unset GBRAIN_DATABASE_URL
|
||||
|
||||
# --- HOME isolation: snapshot real user config before switching ---
|
||||
# Tolerate unset HOME (minimal containers, exotic CI shells) without tripping set -u.
|
||||
REAL_HOME="${HOME:-/tmp}"
|
||||
@@ -68,18 +78,28 @@ 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
|
||||
# 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 and
|
||||
# GBRAIN_REAL_GROK_E2E / GBRAIN_REAL_OPENCODE_E2E, so the real-agent door
|
||||
# suites structurally
|
||||
# cannot fire under this runner (their venue is heavy-tests.yml's direct bun
|
||||
# test). GROK_ also drops an operator's GROK_BIN/GROK_HOME; OPENCODE_ drops
|
||||
# OPENCODE_BIN and the OPENCODE_CONFIG* trio. 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
|
||||
for _e2e_var in $(env | grep -oE '^(CONDUCTOR_|MCP_|OPENCLAW_|HERMES_|GROK_|OPENCODE_|GBRAIN_)[A-Za-z0-9_]*' | sort -u); do
|
||||
case "$_e2e_var" in
|
||||
GBRAIN_HOME) ;; # required for HOME isolation (set above) — keep
|
||||
GBRAIN_TEST_ALLOW_DATABASE_URL) ;; # #3485 preload opt-in (set above) — keep
|
||||
GBRAIN_E2E_ALLOW_DB) ;; # #3485 name-floor opt-in — the guard's own error
|
||||
# message tells operators to set it; stripping it
|
||||
# here would make that escape hatch a dead end
|
||||
*) unset "$_e2e_var" || true ;;
|
||||
esac
|
||||
done
|
||||
@@ -96,7 +116,10 @@ fi
|
||||
if [ "$#" -gt 0 ]; then
|
||||
files=("$@")
|
||||
else
|
||||
files=(test/e2e/*.test.ts)
|
||||
# phantom-redirect lives in test/ (its PGLite arm runs in the unit suite) but
|
||||
# its Postgres arm is only reachable through a DATABASE_URL-bearing lane —
|
||||
# the unit wrappers strip the URL (#3485), so this lane must carry it.
|
||||
files=(test/e2e/*.test.ts test/phantom-redirect-engine-parity.test.ts)
|
||||
fi
|
||||
|
||||
# SHARD env (e.g. SHARD=1/4) keeps every M-th file starting at index N (1-indexed).
|
||||
|
||||
@@ -16,6 +16,10 @@
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
# #3485: the heavy lane runs destructive shell scripts against DATABASE_URL —
|
||||
# apply the shared name floor once here for every script it dispatches.
|
||||
source tests/heavy/_db_floor.sh
|
||||
|
||||
PATTERN="${1:-}"
|
||||
|
||||
heavy_files=()
|
||||
|
||||
@@ -4,6 +4,11 @@
|
||||
# CI runs both; bun run ci:local skips slow tests via run-unit-shard.sh.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# #3485: unit/slow tests need no database — strip ambient DB URLs at this
|
||||
# wrapper boundary so the bunfig preload guard passes and nothing can reach a
|
||||
# real brain. The e2e wrapper (run-e2e.sh) is the only lane that keeps them.
|
||||
unset DATABASE_URL GBRAIN_DATABASE_URL
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
slow_files=()
|
||||
|
||||
@@ -44,8 +44,32 @@
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
# #3485: unit tests need no database — strip ambient DB URLs at this wrapper
|
||||
# boundary so the bunfig preload guard passes and nothing can reach a real
|
||||
# brain. The e2e wrapper (run-e2e.sh) is the only lane that keeps them.
|
||||
unset DATABASE_URL GBRAIN_DATABASE_URL
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# W0 fix-wave (Tier-1 #16): PGLite schema snapshot, DEFAULT-ON for the plain
|
||||
# `bun run test` loop. 500+ test files each cold-boot PGLite + replay 126
|
||||
# migrations without it; the fixture was previously enabled ONLY inside
|
||||
# scripts/ci-local.sh, so the everyday loop paid the full cost. The build
|
||||
# script is idempotent (hash short-circuit) and concurrency-safe (mkdir
|
||||
# lock, D5.8), and its hash folds handler-migration source (D5.13), so an
|
||||
# unconditional call here is cheap and always current. Runs BEFORE the shard
|
||||
# fan-out — shards inherit a finished fixture. Opt out: GBRAIN_NO_SNAPSHOT=1
|
||||
# (the migration-replay canary tests clear the env themselves regardless).
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
if [ "${GBRAIN_NO_SNAPSHOT:-0}" != "1" ]; then
|
||||
if bun run build:pglite-snapshot >/dev/null 2>&1; then
|
||||
export GBRAIN_PGLITE_SNAPSHOT=test/fixtures/pglite-snapshot.tar
|
||||
else
|
||||
echo "[run-unit-parallel] snapshot build failed (non-fatal) — tests run with cold init" >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# CPU detection: Apple Silicon perf cores → Mac total physical → nproc → 4.
|
||||
# Returns a single positive integer.
|
||||
|
||||
@@ -14,6 +14,11 @@
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# #3485: unit/slow tests need no database — strip ambient DB URLs at this
|
||||
# wrapper boundary so the bunfig preload guard passes and nothing can reach a
|
||||
# real brain. The e2e wrapper (run-e2e.sh) is the only lane that keeps them.
|
||||
unset DATABASE_URL GBRAIN_DATABASE_URL
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
# --max-concurrency=N is forwarded to `bun test`. v0.26.4: invoked by
|
||||
|
||||
@@ -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"
|
||||
@@ -67,10 +68,21 @@ CHECKS=(
|
||||
"check:no-double-retry"
|
||||
"check:batch-audit-site"
|
||||
"check:engine-dynamic-import"
|
||||
"check:grok-pin"
|
||||
"check:opencode-pin"
|
||||
"check:pin-doc-privacy"
|
||||
"check:worker-lock-renewal-shape"
|
||||
"check:bootstrap-tag"
|
||||
"check:bootstrap-templates"
|
||||
"check:skill-refs"
|
||||
# W0 fix-wave (Tier-1 #11): guard self-tests — every scanner guard proves it
|
||||
# can fail (bad fixture → exit 1) before it counts as coverage. Registry:
|
||||
# scripts/guards-manifest.tsv (package.json's stale `check:all` copy deleted).
|
||||
"check:guard-self-test"
|
||||
# Previously reachable ONLY from the deleted check:all (i.e. never run):
|
||||
"check:newlines"
|
||||
"check:exports-count"
|
||||
"check:no-legacy-getconnection"
|
||||
"typecheck"
|
||||
)
|
||||
|
||||
|
||||
@@ -103,7 +103,6 @@
|
||||
"test/cli-help-discoverability.test.ts": 1670,
|
||||
"test/cli-multimodal-integration.test.ts": 26677,
|
||||
"test/cli-options.test.ts": 1658,
|
||||
"test/cli-pty-runner.test.ts": 16,
|
||||
"test/cli-query-image.test.ts": 56,
|
||||
"test/cli.test.ts": 2633,
|
||||
"test/code-callers-cli.test.ts": 2,
|
||||
|
||||
+3
-2
@@ -102,11 +102,12 @@ 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) |
|
||||
| "Run dream", "process today's session", "synthesize my conversations", "consolidate yesterday's conversations", "what patterns did you see", "did the dream cycle run" | `skills/maintain/SKILL.md` (dream cycle section) |
|
||||
| "Run dream", "process today's session", "synthesize my conversations", "consolidate yesterday's conversations", "what patterns did you see", "did the dream cycle run", "retriage the backlog", "re-score the triage" | `skills/maintain/SKILL.md` (dream cycle section) |
|
||||
| "Brain health", "what features am I missing", "brain score" | Run `gbrain features --json` |
|
||||
| "Set up autopilot", "run brain maintenance", "keep brain updated" | Run `gbrain autopilot --install --repo ~/brain` |
|
||||
| "Upgrade gbrain", "update gbrain", "gbrain update available", `UPGRADE_AVAILABLE`, "is gbrain up to date" | `skills/gbrain-upgrade/SKILL.md` |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -12,7 +12,7 @@ Four tiers:
|
||||
|
||||
| Tier | Purpose | Default | Examples |
|
||||
|---|---|---|---|
|
||||
| `utility` | fast classification, expansion, verdict, dedup | `claude-haiku-4-5-20251001` | query expansion, facts contradiction classifier, dream synthesize verdict |
|
||||
| `utility` | fast classification, expansion, verdict, dedup | `claude-haiku-4-5-20251001` | query expansion, facts contradiction classifier, dream triage judge (prefers `models.dream.triage`) |
|
||||
| `reasoning` | default chat, synthesis, generation | `claude-sonnet-4-6` | gateway chat, dream synthesize, patterns, facts extraction |
|
||||
| `deep` | slow, expensive reasoning | `claude-opus-4-7` | `gbrain think`, auto-think, cross-modal eval slot B |
|
||||
| `subagent` | Anthropic-only multi-turn tool loop | `claude-sonnet-4-6` | `gbrain agent run` |
|
||||
@@ -28,6 +28,10 @@ Override priority (highest first):
|
||||
7. Tier default (the table above)
|
||||
8. Hardcoded caller fallback
|
||||
|
||||
One exception: the dream triage judge pre-reads `models.dream.triage` first —
|
||||
when that key is set, it wins over this entire chain (`gbrain models` reports
|
||||
it as the effective route).
|
||||
|
||||
Power-user recipes:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -49,9 +49,11 @@ upstream: conversation-history+transcript-save@fc834ee
|
||||
|
||||
Two halves of one loop:
|
||||
|
||||
1. **IMPORT** — raw export or session log → one dated markdown page per
|
||||
conversation under `conversations/` → `gbrain import`/`gbrain sync` →
|
||||
parser validation → fact extraction → gap check.
|
||||
1. **IMPORT** — raw export or session log → dated markdown pages under
|
||||
`conversations/` (the native importer writes them directly and splits
|
||||
long sessions into parts; the manual path converts one page per
|
||||
conversation, then `gbrain import`/`gbrain sync`) → parser validation →
|
||||
fact extraction → gap check.
|
||||
2. **RETRIEVE** — search the archive, pull threads, build timelines, and
|
||||
answer "when did I first discuss X".
|
||||
|
||||
@@ -59,11 +61,29 @@ Years of AI-assistant history is one of the largest personal corpora most
|
||||
users own. This skill makes it first-class brain content instead of a JSON
|
||||
blob in a downloads folder.
|
||||
|
||||
**No native raw-export importer exists.** `gbrain import <dir>` ingests
|
||||
markdown directories; nothing in the CLI parses a provider's raw
|
||||
`conversations.json` directly. The conversion step below is agent work.
|
||||
(A native `gbrain import --format chatgpt|claude` is a filed follow-up; until
|
||||
it lands, this procedure is the supported path.)
|
||||
**A native importer now exists: `gbrain transcripts ingest`.** It parses
|
||||
agent session logs (Claude Code, Codex, OpenClaw, Hermes) AND extracted
|
||||
consumer exports (ChatGPT `conversations.json`, Claude.ai export) directly:
|
||||
detection, secret redaction, imessage-slack rendering, long-session
|
||||
splitting, and idempotent re-runs are all native. Prefer it over the manual
|
||||
procedure whenever the source is one of those six formats:
|
||||
|
||||
```
|
||||
gbrain transcripts ingest ~/Downloads/conversations.json # unzip first
|
||||
gbrain transcripts ingest # discover harness logs
|
||||
gbrain transcripts status # found vs imported gaps
|
||||
```
|
||||
|
||||
Native-vs-manual delta to know: the native lane redacts SECRETS (key
|
||||
patterns) plus your `~/.gbrain/harvest-private-patterns.txt` regexes and
|
||||
counts agent-directed imperatives into frontmatter, but broad PII detection
|
||||
(names, phones, addresses) remains YOUR review pass — the manual procedure's
|
||||
human scrub step still applies to sensitive corpora. Two more deltas: the
|
||||
native lane caps each message at ~4K characters in the page body (readable
|
||||
archive, not verbatim — the session file named in `source_uri` stays the
|
||||
verbatim record), and tool/thinking traffic appears only as one-line
|
||||
placeholders. Providers without a native adapter (e.g. Perplexity) keep
|
||||
using the manual conversion below.
|
||||
|
||||
## Where Conversations Live
|
||||
|
||||
|
||||
+28
-12
@@ -18,6 +18,8 @@ triggers:
|
||||
- "populate links"
|
||||
- "backfill graph"
|
||||
- "extract timeline entries"
|
||||
- "retriage the backlog"
|
||||
- "re-score the triage"
|
||||
- "run dream"
|
||||
- "process today's session"
|
||||
- "process yesterday's transcripts"
|
||||
@@ -116,7 +118,8 @@ gbrain extract timeline --dir ~/brain
|
||||
|
||||
### Dream cycle (v0.23): synthesize + patterns
|
||||
|
||||
`gbrain dream` runs the full 8-phase maintenance cycle:
|
||||
`gbrain dream` runs the full maintenance cycle (core phases shown; opt-in
|
||||
phases like atoms/concepts/drift slot in between):
|
||||
|
||||
```
|
||||
lint -> backlinks -> sync -> synthesize -> extract -> patterns -> embed -> orphans
|
||||
@@ -124,14 +127,25 @@ lint -> backlinks -> sync -> synthesize -> extract -> patterns -> embed -> orpha
|
||||
|
||||
The two new phases consolidate yesterday's conversations into long-term memory:
|
||||
|
||||
**Synthesize phase:** reads transcripts from `dream.synthesize.session_corpus_dir`,
|
||||
runs a cheap Haiku verdict (cached in `dream_verdicts`) to filter routine
|
||||
ops sessions, then fans out one Sonnet subagent per worth-processing
|
||||
transcript. Each subagent writes reflections (`wiki/personal/reflections/...`),
|
||||
originals (`wiki/originals/ideas/...`), and people timeline entries. The
|
||||
orchestrator collects the slugs from `subagent_tool_executions` (NOT
|
||||
`pages.updated_at` — that would pick up unrelated writes) and reverse-renders
|
||||
each new page from DB → markdown on disk.
|
||||
**Synthesize phase (two-stage cascade):** reads transcripts from
|
||||
`dream.synthesize.session_corpus_dir`, then triages before it spends: a cheap
|
||||
utility-tier judge (`models.dream.triage`) scores every new file 0–1 for
|
||||
salience and pre-extracts candidate quotes + entities, cached in
|
||||
`dream_verdicts` with the judging model + prompt version (bounded per cycle
|
||||
by `dream.triage.max_ms`, default 5 min — deferred files retry next cycle,
|
||||
never silently rejected). Only files scoring
|
||||
at or above `dream.triage.threshold` (default 0.5 — applied at read time, so
|
||||
retuning the threshold re-gates with zero new LLM calls) fan out one synthesis
|
||||
subagent per transcript chunk, each primed with the triage map and capped at
|
||||
`dream.synthesize.max_turns` (default 16). Each subagent writes reflections
|
||||
(`wiki/personal/reflections/...`), originals (`wiki/originals/ideas/...`), and
|
||||
people timeline entries. The orchestrator collects the slugs from
|
||||
`subagent_tool_executions` (NOT `pages.updated_at` — that would pick up
|
||||
unrelated writes) and reverse-renders each new page from DB → markdown on
|
||||
disk. To re-apply the gate after retuning the threshold or drain a queued
|
||||
backlog, run `gbrain dream retriage --dry-run` (zero LLM calls, cached
|
||||
scores only) then `gbrain dream retriage --reconcile-queue`; `--force`
|
||||
re-judges everything from scratch.
|
||||
|
||||
**Patterns phase:** runs after `extract` (so the graph state is fresh).
|
||||
Reads recent reflections within `dream.patterns.lookback_days` (default 30),
|
||||
@@ -164,15 +178,17 @@ timestamp is stored in `dream.synthesize.last_completion_ts` and is written
|
||||
ONLY on successful runs (not on skipped/failed). Explicit `--input` /
|
||||
`--date` / `--from` / `--to` invocations bypass cooldown.
|
||||
|
||||
**`--dry-run` semantics:** runs the cheap Haiku significance filter (caches
|
||||
verdicts) but skips the Sonnet synthesis pass. NOT zero LLM calls.
|
||||
**`--dry-run` semantics:** runs the scored triage pass (judges + caches
|
||||
verdicts for new files) but skips the synthesis subagents. NOT zero LLM
|
||||
calls — for a zero-call preview from cached scores use
|
||||
`gbrain dream retriage --dry-run` instead.
|
||||
|
||||
**Configure synthesize on a fresh brain:**
|
||||
```bash
|
||||
gbrain config set dream.synthesize.session_corpus_dir /path/to/transcripts
|
||||
gbrain config set dream.synthesize.enabled true
|
||||
gbrain dream --phase synthesize --dry-run --json # preview
|
||||
gbrain dream # full 8-phase cycle
|
||||
gbrain dream # full cycle
|
||||
```
|
||||
|
||||
**Invocation patterns:**
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"RESOLVER.md": "3d32ec5cd8c15d83b18469277a5efc269bb6b60e67f4542de5a28a76760c36cd",
|
||||
"RESOLVER.md": "36b43c65a41e6fce894b9559db2bce0a53f06a99450410e498323c12e12e92bb",
|
||||
"_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",
|
||||
@@ -48,7 +48,7 @@
|
||||
"conventions/cron-via-minions.md": "badb1cd6cd825d6f1ac0b6b28cc47e5d80facc783a3e59a14146ae901ee0f933",
|
||||
"conventions/cross-modal.yaml": "c012c3d72614a87b1ee698173dce2a0fb0d057a54df7aab87993c4b07fff6280",
|
||||
"conventions/exec-output.md": "2bf371ac3ec4987eff7cc13cd3ea8cc97c46bd43f588eec024ff27f3171bc58f",
|
||||
"conventions/model-routing.md": "fb7ae8746a578500d6789b68ff40049037aa4d337b65b42f7c1745ae7080c2db",
|
||||
"conventions/model-routing.md": "8b28aa706436e7b68493ec481e12be6309b0af1a553930e8b1029d675fa4b3ad",
|
||||
"conventions/path-discipline.md": "8af5415721bd115e6979c96688bcf542a32675809ada9aaedbb6706a40926954",
|
||||
"conventions/quality.md": "8aa681001114689d34268ccadaf0e2ff07b8f68aa5987c093a8c4a7a744f12a6",
|
||||
"conventions/regex-discipline.md": "d96a9baa6f27184e165889a9c655607366a739d851684d9f41cdec294f99edac",
|
||||
@@ -58,7 +58,7 @@
|
||||
"conventions/subagent-routing.md": "8b8830b815a9a8581a12b489f966c0b0a39eb9b5f66e905a691a03653eef348d",
|
||||
"conventions/test-before-bulk.md": "6b2c52cda9e2cd5f04c15152b3d92aeb7187ab193a15082be0f8a3991a6a5725",
|
||||
"conventions/untrusted-content.md": "259384d490892cd0e1e8e054decf752d7354f516c83aee57b332c1a96aac6a6e",
|
||||
"conversation-archive/SKILL.md": "867d3a202ce500027ed2ab85edd9d3359d677aa7a180105f2b7db12ad3492701",
|
||||
"conversation-archive/SKILL.md": "4e1dea00f5e1e16e749a42f295fdccf556199d4400a2ba1b891aa91839e37214",
|
||||
"conversation-archive/routing-eval.jsonl": "ae087a84b1fd5b108b7cdab8d035a09b3ccecd8aad53ba5f71e463059108cfca",
|
||||
"correction-pipeline/SKILL.md": "caf1264b7afec46569d30f6d92b07f37ae375e3f4e6aeddd58866aec327053de",
|
||||
"correction-pipeline/routing-eval.jsonl": "7f8d96606a8d7bed3d79fdcee6904764c8abb9fa0b506adb414b5c4805b69d0b",
|
||||
@@ -88,7 +88,7 @@
|
||||
"idea-lineage/routing-eval.jsonl": "ee2e00704b9accb7dd58bb8f126a3bc04a2c40be499180fa505dbf6d5061cd41",
|
||||
"ingest/SKILL.md": "dc40ecc0072806fb8c7bb6ab9cf1f103842e05653eb55d67632d7e3ffc4dd7d2",
|
||||
"install/SKILL.md": "881bd0a422f34c6df4642aae66c51e2a4cc18ad5ca6d0b52d44b4de93512a3c4",
|
||||
"maintain/SKILL.md": "59da3f0227a2b41ed9c3beb334322f1ef1dbd80af733a587c0a4687a707b9815",
|
||||
"maintain/SKILL.md": "33e48e31baf89b6b257ad863cdb9de444777bc1272f5ed8c2b28be3a54cbaa14",
|
||||
"manifest.json": "03471868cce05fa38af6f793da54e2fc11f77ef778271a596d75bc29f9ec4c73",
|
||||
"measure-before-you-fix/SKILL.md": "1fd3b40ab65cbd08f50dea16107701859165469be3c85c57d779c7b4bbf92db8",
|
||||
"measure-before-you-fix/routing-eval.jsonl": "0661df9974a9cfe31216d574b1db0ef341945c2eb844ebf4ab6920fcbbc90d6c",
|
||||
@@ -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",
|
||||
|
||||
+128
-19
@@ -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';
|
||||
@@ -153,6 +154,19 @@ const CLI_ONLY_SELF_HELP = new Set([
|
||||
// would leave that help dead code behind the generic stub (the init.ts:117
|
||||
// trap ENG-2 names).
|
||||
'bootstrap', 'hook', 'sweep',
|
||||
// cathedral-4: transcripts ships its own HELP (the ingest import lane +
|
||||
// the v0.29 recent reader). Without this the generic stub hides both.
|
||||
'transcripts',
|
||||
// jobs ships JOBS_HELP + a per-subcommand record (JOBS_SUBCOMMAND_HELP) in
|
||||
// jobs.ts, guarded BEFORE the thin-client refusal and the subcommand switch
|
||||
// so `jobs work --help` prints help instead of starting a worker daemon.
|
||||
// Without this entry the generic stub hid the worker entry point entirely.
|
||||
'jobs',
|
||||
// #4152: dream ships its own printHelp AND the `dream retriage --help`
|
||||
// subverb help (dispatched engine-free before parseArgs). The generic stub
|
||||
// would hide both — `gbrain dream retriage --help` printed the one-line
|
||||
// dream stub instead of the retriage contract (outside-voice CX9).
|
||||
'dream',
|
||||
]);
|
||||
|
||||
/**
|
||||
@@ -171,6 +185,13 @@ const SELF_HELP_WITHOUT_ENGINE: Record<string, () => Promise<(engine: never, arg
|
||||
maintain: async () => (await import('./commands/maintain.ts')).runMaintain as never,
|
||||
'extract-conversation-facts': async () =>
|
||||
(await import('./commands/extract-conversation-facts.ts')).runExtractConversationFacts as never,
|
||||
transcripts: async () => (await import('./commands/transcripts.ts')).runTranscripts as never,
|
||||
// runJobs accepts BrainEngine | null and its help guard returns before any
|
||||
// engine (or subcommand body) is touched.
|
||||
jobs: async () => (await import('./commands/jobs.ts')).runJobs as never,
|
||||
// runDream accepts BrainEngine | null; --help (and `retriage --help`) is
|
||||
// answered before any engine-bearing work per the dream.ts IRON RULE.
|
||||
dream: async () => (await import('./commands/dream.ts')).runDream as never,
|
||||
};
|
||||
|
||||
/** Returns true when the command's own help was printed. */
|
||||
@@ -259,12 +280,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 +308,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 +740,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 +1331,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 +1434,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();
|
||||
@@ -2268,16 +2364,26 @@ async function handleCliOnly(command: string, args: string[]) {
|
||||
try {
|
||||
switch (command) {
|
||||
case 'import': {
|
||||
const { runImport } = await import('./commands/import.ts');
|
||||
const { runImport, ImportAbortError } = await import('./commands/import.ts');
|
||||
// v0.41 (Codex r2 #3 fix): honor errors counter for exit code.
|
||||
// runImport's per-file catch already records failures, but the
|
||||
// CLI was discarding the result so the process exited 0 even
|
||||
// when files failed (e.g. content-sanity hard-block throws,
|
||||
// size-cap throws, parse errors). Surface non-zero on errors > 0
|
||||
// so wrappers (sync, CI scripts, `&& gbrain doctor`) propagate.
|
||||
const importResult = await runImport(engine, args);
|
||||
if (importResult.errors > 0) {
|
||||
setCliExitVerdict(1);
|
||||
try {
|
||||
const importResult = await runImport(engine, args);
|
||||
if (importResult.errors > 0) {
|
||||
setCliExitVerdict(1);
|
||||
}
|
||||
} catch (e) {
|
||||
// W0 (Tier-1 #5): runImport throws typed aborts instead of
|
||||
// process.exit(1) so in-process callers (sync_brain MCP op,
|
||||
// autopilot, minion handler) survive a preflight failure. The CLI
|
||||
// keeps the exact pre-fix behavior: message already printed at the
|
||||
// throw site, exit non-zero here.
|
||||
if (e instanceof ImportAbortError) process.exit(e.exitCode);
|
||||
throw e;
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -3028,7 +3134,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
|
||||
@@ -3101,7 +3207,7 @@ TOOLS
|
||||
orphans [--json] [--count] Find pages with no inbound wikilinks
|
||||
salience [--days N] [--kind P] v0.29: pages ranked by emotional + activity salience
|
||||
anomalies [--since D] [--sigma N] v0.29: cohort-based statistical anomalies (tag, type)
|
||||
transcripts recent [--days N] v0.29: recent raw .txt transcripts (local-only)
|
||||
transcripts <ingest|status|recent> v0.46: import agent session logs + chat exports (local-only)
|
||||
dream [--dry-run] [--json] Run the overnight maintenance cycle once (cron-friendly).
|
||||
See also: autopilot --install (continuous daemon).
|
||||
check-resolvable [--json] [--fix] Validate skill tree (reachability/MECE/DRY)
|
||||
@@ -3153,7 +3259,9 @@ JOBS (Minions)
|
||||
jobs retry <id> Re-queue failed/dead job
|
||||
jobs prune [--older-than 30d] Clean old jobs
|
||||
jobs stats Job health dashboard
|
||||
jobs watch [--follow] Live queue dashboard
|
||||
jobs work [--queue Q] Start worker daemon (Postgres only)
|
||||
jobs supervisor [start|status|stop] Auto-restarting worker wrapper
|
||||
|
||||
ADMIN
|
||||
stats Brain statistics
|
||||
@@ -3168,8 +3276,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
@@ -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
@@ -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
|
||||
`);
|
||||
|
||||
@@ -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
|
||||
@@ -70,8 +69,13 @@ export interface FanoutOpts {
|
||||
}
|
||||
|
||||
export interface FanoutResult {
|
||||
/** Source ids dispatched this tick. */
|
||||
/** Source ids whose submission INSERTED a fresh job this tick. */
|
||||
dispatched: string[];
|
||||
/** Source ids whose submission coalesced onto an existing pending job
|
||||
* (maxPending single-flight or same-slot idempotency) — work is in
|
||||
* flight, but no new row was created. Kept separate so no surface
|
||||
* claims a dispatch that didn't insert. */
|
||||
coalesced: string[];
|
||||
/** Source ids skipped because their last_full_cycle_at is still fresh. */
|
||||
skipped_fresh: string[];
|
||||
/** Source ids beyond the fanoutMax cap (will retry next tick). */
|
||||
@@ -81,6 +85,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 +186,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 +338,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[] } {
|
||||
@@ -395,18 +405,38 @@ export async function dispatchPerSource(
|
||||
{ repoPath: opts.repoPath },
|
||||
{
|
||||
queue: 'default',
|
||||
// Slot key dedups repeats within one slot; maxPending: 1 is the
|
||||
// cross-slot guard — an in-flight (waiting or live-lock active)
|
||||
// cycle suppresses re-dispatch even after the slot rotates. This
|
||||
// closes the unbounded-duplicate loop: slot rotation used to mint
|
||||
// a fresh key every baseInterval while maxWaiting ignored the
|
||||
// active row, growing the queue forever when a cycle stalled.
|
||||
idempotency_key: `autopilot-cycle:${opts.slot}`,
|
||||
max_attempts: 2,
|
||||
timeout_ms: opts.timeoutMs,
|
||||
maxWaiting: 1,
|
||||
maxPending: 1,
|
||||
},
|
||||
);
|
||||
if (opts.jsonMode) {
|
||||
if (job.coalesced) {
|
||||
if (opts.jsonMode) {
|
||||
emit(JSON.stringify({ event: 'dispatch_coalesced', job_id: job.id, mode: 'legacy', slot: opts.slot }));
|
||||
} else {
|
||||
log(`[dispatch] coalesced onto job #${job.id} autopilot-cycle (legacy single-source; already in flight)`);
|
||||
}
|
||||
} else if (opts.jsonMode) {
|
||||
emit(JSON.stringify({ event: 'dispatched', job_id: job.id, mode: 'legacy', slot: opts.slot }));
|
||||
} else {
|
||||
log(`[dispatch] job #${job.id} autopilot-cycle (legacy single-source)`);
|
||||
}
|
||||
return { dispatched: [], skipped_fresh: [], skipped_cap: [], skipped_cooldown: [], legacy_fallback: true };
|
||||
return {
|
||||
dispatched: [],
|
||||
coalesced: [],
|
||||
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,9 +456,17 @@ 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[] = [];
|
||||
const coalesced: string[] = [];
|
||||
for (const src of dispatch) {
|
||||
try {
|
||||
const shouldPull = sourceConfigHasRemoteUrl(src.config);
|
||||
@@ -451,26 +489,43 @@ export async function dispatchPerSource(
|
||||
idempotency_key: `autopilot-cycle:${src.id}:${opts.slot}`,
|
||||
max_attempts: 2,
|
||||
timeout_ms: opts.timeoutMs,
|
||||
// DELIBERATELY no maxWaiting: 1 here. maxWaiting is per
|
||||
// (name, queue), so it would coalesce all N per-source jobs
|
||||
// sharing name='autopilot-cycle' down to ONE waiting job —
|
||||
// killing the fan-out. The per-source idempotency_key
|
||||
// already provides the right dedup granularity (one job per
|
||||
// source per slot, regardless of how many ticks try).
|
||||
// Still DELIBERATELY no maxWaiting here (its NULL-as-wildcard
|
||||
// source scope would coalesce N per-source jobs down to one).
|
||||
// maxPending is safe: its scope is EXACT on
|
||||
// COALESCE(data.sourceId, data.source_id), so each source keeps
|
||||
// an independent single-flight cap — and unlike the slot key, it
|
||||
// suppresses cross-slot re-dispatch while THIS source's cycle is
|
||||
// still in flight (waiting or live-lock active).
|
||||
maxPending: 1,
|
||||
},
|
||||
);
|
||||
dispatched.push(src.id);
|
||||
if (opts.jsonMode) {
|
||||
emit(JSON.stringify({
|
||||
event: 'dispatched',
|
||||
job_id: job.id,
|
||||
mode: 'per_source',
|
||||
source_id: src.id,
|
||||
pull: shouldPull,
|
||||
slot: opts.slot,
|
||||
}));
|
||||
if (job.coalesced) {
|
||||
coalesced.push(src.id);
|
||||
if (opts.jsonMode) {
|
||||
emit(JSON.stringify({
|
||||
event: 'dispatch_coalesced',
|
||||
job_id: job.id,
|
||||
mode: 'per_source',
|
||||
source_id: src.id,
|
||||
slot: opts.slot,
|
||||
}));
|
||||
} else {
|
||||
log(`[dispatch] coalesced onto job #${job.id} autopilot-cycle source=${src.id} (already in flight)`);
|
||||
}
|
||||
} else {
|
||||
log(`[dispatch] job #${job.id} autopilot-cycle source=${src.id}${shouldPull ? ' pull=yes' : ''}`);
|
||||
dispatched.push(src.id);
|
||||
if (opts.jsonMode) {
|
||||
emit(JSON.stringify({
|
||||
event: 'dispatched',
|
||||
job_id: job.id,
|
||||
mode: 'per_source',
|
||||
source_id: src.id,
|
||||
pull: shouldPull,
|
||||
slot: opts.slot,
|
||||
}));
|
||||
} else {
|
||||
log(`[dispatch] job #${job.id} autopilot-cycle source=${src.id}${shouldPull ? ' pull=yes' : ''}`);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Per-source submit failure does NOT abort the tick (codex E1 F1
|
||||
@@ -505,10 +560,12 @@ export async function dispatchPerSource(
|
||||
|
||||
return {
|
||||
dispatched,
|
||||
coalesced,
|
||||
skipped_fresh: skippedFresh.map(s => s.id),
|
||||
skipped_cap: skippedCap.map(s => s.id),
|
||||
skipped_cooldown: skippedCooldown.map(s => s.id),
|
||||
legacy_fallback: false,
|
||||
all_sources_fresh: skippedFresh.length === sources.length,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -526,16 +583,18 @@ export function isGlobalMaintenanceStale(lastGlobalAtIso: string | null, now = D
|
||||
* #2194 fix #3 / #2227 bug #3 — dispatch the single brain-wide maintenance job
|
||||
* that runs the `global` cycle phases (embed, orphans, purge, …) ONCE per
|
||||
* window, instead of N per-source cycles each running them concurrently (the
|
||||
* RSS blowout). Single-flight is structural: one `idempotency_key` +
|
||||
* `maxWaiting:1`, so a slow run never stacks. Gated on `autopilot.last_global_at`
|
||||
* (stamped by the handler on success). Postgres-only fan-out concern; on PGLite
|
||||
* the file lock already serializes, but the job is still correct there.
|
||||
* RSS blowout). Single-flight is structural: one `idempotency_key` per slot +
|
||||
* `maxPending:1` (an in-flight waiting/live-lock-active run suppresses
|
||||
* re-dispatch even across slot rotation), so a slow run never stacks. Gated on
|
||||
* `autopilot.last_global_at` (stamped by the handler on success). Postgres-only
|
||||
* fan-out concern; on PGLite the file lock already serializes, but the job is
|
||||
* still correct there.
|
||||
*/
|
||||
export async function dispatchGlobalMaintenance(
|
||||
engine: BrainEngine,
|
||||
queue: MinionQueue,
|
||||
opts: { repoPath: string; slot: string; timeoutMs: number; jsonMode: boolean; emit?: (l: string) => void; log?: (l: string) => void },
|
||||
): Promise<{ dispatched: boolean; reason: 'stale' | 'fresh' }> {
|
||||
): Promise<{ dispatched: boolean; coalesced?: boolean; reason: 'stale' | 'fresh' }> {
|
||||
const emit = opts.emit ?? ((line) => process.stderr.write(line + '\n'));
|
||||
const log = opts.log ?? ((line) => console.log(line));
|
||||
|
||||
@@ -555,14 +614,26 @@ export async function dispatchGlobalMaintenance(
|
||||
{ repoPath: opts.repoPath, phases: GLOBAL_PHASES },
|
||||
{
|
||||
queue: 'default',
|
||||
// Structural single-flight: one global job per slot; maxWaiting:1 coalesces
|
||||
// any surplus so a slow brain-wide pass never stacks duplicates.
|
||||
// Structural single-flight: one global job per slot; maxPending:1
|
||||
// coalesces any surplus — including across slot rotation while a slow
|
||||
// brain-wide pass is still in flight — so duplicates never stack.
|
||||
idempotency_key: `autopilot-global:${opts.slot}`,
|
||||
max_attempts: 2,
|
||||
timeout_ms: opts.timeoutMs,
|
||||
maxWaiting: 1,
|
||||
maxPending: 1,
|
||||
},
|
||||
);
|
||||
if (job.coalesced) {
|
||||
if (opts.jsonMode) {
|
||||
emit(JSON.stringify({ event: 'dispatch_coalesced', job_id: job.id, mode: 'global_maintenance', slot: opts.slot }));
|
||||
} else {
|
||||
log(`[dispatch] coalesced onto job #${job.id} autopilot-global-maintenance (already in flight)`);
|
||||
}
|
||||
// dispatched: false — no row was inserted (same honest-dispatch contract
|
||||
// as dispatchPerSource, where coalesced sources are excluded from
|
||||
// `dispatched`). The coalesced flag says work is already in flight.
|
||||
return { dispatched: false, coalesced: true, reason: 'stale' };
|
||||
}
|
||||
if (opts.jsonMode) {
|
||||
emit(JSON.stringify({ event: 'dispatched', job_id: job.id, mode: 'global_maintenance', slot: opts.slot }));
|
||||
} else {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+71
-25
@@ -25,6 +25,11 @@ 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 {
|
||||
@@ -41,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
|
||||
@@ -244,19 +254,22 @@ 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' };
|
||||
|
||||
@@ -268,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>'}` };
|
||||
}
|
||||
|
||||
@@ -875,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).
|
||||
//
|
||||
@@ -1121,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) {
|
||||
@@ -1181,13 +1205,23 @@ 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.
|
||||
// Coalesced counts as work-in-flight: before dispatched/coalesced
|
||||
// split, a coalesced submission advanced this clock via dispatched —
|
||||
// keep that behavior, or an all-coalesced tick (single-flight
|
||||
// suppression) would retake the full-cycle branch every tick and
|
||||
// starve the targeted-plan path for the whole in-flight window.
|
||||
if (result.dispatched.length > 0 || result.coalesced.length > 0 || result.legacy_fallback || result.all_sources_fresh) {
|
||||
lastFullCycleAt = Date.now();
|
||||
}
|
||||
if (jsonMode) {
|
||||
process.stderr.write(JSON.stringify({
|
||||
event: 'fanout_summary',
|
||||
dispatched: result.dispatched,
|
||||
coalesced: result.coalesced,
|
||||
skipped_fresh: result.skipped_fresh,
|
||||
skipped_cap: result.skipped_cap,
|
||||
skipped_cooldown: result.skipped_cooldown,
|
||||
@@ -1197,7 +1231,8 @@ export async function runAutopilot(engine: BrainEngine, args: string[]) {
|
||||
}) + '\n');
|
||||
} else if (!result.legacy_fallback) {
|
||||
console.log(
|
||||
`[dispatch] fanout: ${result.dispatched.length} dispatched, ` +
|
||||
`[dispatch] fanout: ${result.dispatched.length} dispatched` +
|
||||
`${result.coalesced.length > 0 ? ` (${result.coalesced.length} coalesced onto in-flight)` : ''}, ` +
|
||||
`${result.skipped_fresh.length} fresh, ${result.skipped_cap.length} capped, ` +
|
||||
`${result.skipped_cooldown.length} cooldown ` +
|
||||
`(score=${score}, max=${fanoutMax})`,
|
||||
@@ -1205,15 +1240,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,
|
||||
@@ -1224,7 +1261,16 @@ export async function runAutopilot(engine: BrainEngine, args: string[]) {
|
||||
submitOpts,
|
||||
isProtected ? { allowProtectedSubmit: true } : undefined,
|
||||
);
|
||||
if (jsonMode) {
|
||||
// Honest-dispatch contract (same as the fanout paths): a
|
||||
// coalesced submission never claims a dispatch that didn't
|
||||
// insert a row.
|
||||
if (job.coalesced) {
|
||||
if (jsonMode) {
|
||||
process.stderr.write(JSON.stringify({ event: 'dispatch_coalesced', job_id: job.id, mode: 'targeted', step: step.id, score, plan_size: plan.length }) + '\n');
|
||||
} else {
|
||||
console.log(`[dispatch] coalesced onto job #${job.id} ${step.job} (targeted: ${step.id}; already in flight)`);
|
||||
}
|
||||
} else if (jsonMode) {
|
||||
process.stderr.write(JSON.stringify({ event: 'dispatched', job_id: job.id, mode: 'targeted', step: step.id, score, plan_size: plan.length }) + '\n');
|
||||
} else {
|
||||
console.log(`[dispatch] job #${job.id} ${step.job} (targeted: ${step.id}; score=${score})`);
|
||||
|
||||
+785
-56
File diff suppressed because it is too large
Load Diff
+620
-69
@@ -17,19 +17,26 @@
|
||||
*/
|
||||
|
||||
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 { GrokRunner } from '../core/claw-test/runners/grok.ts';
|
||||
import { OpencodeRunner } from '../core/claw-test/runners/opencode.ts';
|
||||
import { createTranscriptSink } from '../core/claw-test/transcript-capture.ts';
|
||||
|
||||
// Ensure built-in runners are registered.
|
||||
registerAgentRunner('openclaw', () => new OpenClawRunner());
|
||||
registerAgentRunner('hermes', () => new HermesRunner());
|
||||
registerAgentRunner('grok', () => new GrokRunner());
|
||||
registerAgentRunner('opencode', () => new OpencodeRunner());
|
||||
|
||||
interface HarnessOpts {
|
||||
scenario: string;
|
||||
@@ -38,8 +45,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 +59,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 +90,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 +119,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 +150,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 +164,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 +243,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 +282,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 +315,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 +371,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 +382,7 @@ async function runScripted(
|
||||
kind: 'phase-marker',
|
||||
marker: 'end',
|
||||
source: 'harness',
|
||||
agent: opts.agent,
|
||||
agent: 'scripted',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -268,7 +399,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 +412,25 @@ 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',
|
||||
// Official xAI CLI only — the community superagent-ai grok-cli ships a
|
||||
// colliding `grok` binary (docs/mcp/GROK-CLI-PIN.md).
|
||||
grok: 'install grok (npm: @xai-official/grok, or https://x.ai/cli/install.sh) or set GROK_BIN',
|
||||
// SST terminal agent — not OpenClaw, and not the renamed-to-Crush ancestor
|
||||
// that shares the binary name (docs/mcp/OPENCODE-CLI-PIN.md).
|
||||
opencode: 'install opencode (npm: opencode-ai, or https://opencode.ai/install) or set OPENCODE_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 +452,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 +504,7 @@ async function runLive(
|
||||
cwd: ctx.runRoot,
|
||||
brief,
|
||||
env,
|
||||
timeoutMs: SUBPROCESS_TIMEOUT_MS,
|
||||
timeoutMs: LIVE_AGENT_TIMEOUT_MS,
|
||||
transcriptSink: sink,
|
||||
});
|
||||
} finally {
|
||||
@@ -340,9 +522,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 +795,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 +857,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 +885,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 +898,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 +959,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 +1000,15 @@ Defaults:
|
||||
Scripted mode runs canonical commands without an LLM (CI gate).
|
||||
Live mode spawns a real agent and lets it drive (~5–10 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 opencode
|
||||
gbrain claw-test --live --agent hermes
|
||||
gbrain claw-test --live --agent grok`);
|
||||
}
|
||||
|
||||
+176
-148
@@ -9,7 +9,7 @@
|
||||
* needed for the connection.
|
||||
*
|
||||
* gbrain connect <mcp-url> [--token <bearer>] [--name gbrain]
|
||||
* [--agent claude-code|codex|perplexity|generic]
|
||||
* [--agent claude-code|codex|opencode|perplexity|generic]
|
||||
* [--oauth [--register | --client-id ID --client-secret SECRET] [--scopes "read write"]]
|
||||
* [--install] [--yes] [--json] [--show-token] [--force]
|
||||
* [--timeout-ms N]
|
||||
@@ -27,32 +27,79 @@
|
||||
* only; --install runs it).
|
||||
* - codex: `codex mcp add <name> --url <url> --bearer-token-env-var
|
||||
* GBRAIN_REMOTE_TOKEN` (bearer via env var; --install runs it).
|
||||
* - opencode: `opencode mcp add <name> --url <url> --header
|
||||
* "Authorization=Bearer {env:GBRAIN_REMOTE_TOKEN}"` (the interpolation is
|
||||
* stored literally; --install writes the entry directly via
|
||||
* opencode-json.ts — no binary needed).
|
||||
* - perplexity: GUI connector (Settings → Connectors). Supports bearer or
|
||||
* OAuth; no --install.
|
||||
* - generic: prints the connector fields for any other MCP client.
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'child_process';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { dirname } from 'node:path';
|
||||
import type { ConnectProbeResult } from '../core/connect-probe.ts';
|
||||
import { probeBrainIdentity, DEFAULT_PROBE_TIMEOUT_MS } from '../core/connect-probe.ts';
|
||||
import { opencodeGlobalConfigPath } from '../core/bootstrap/host-specs.ts';
|
||||
import { acquireBootstrapLock } from '../core/bootstrap/lock.ts';
|
||||
import {
|
||||
GBRAIN_REMOTE_TOKEN_ENV,
|
||||
reconcileOpencodeSiblingGlobal,
|
||||
writeOpencodeMcpEntry,
|
||||
} from '../core/bootstrap/opencode-json.ts';
|
||||
import { promptLine } from '../core/cli-util.ts';
|
||||
import {
|
||||
NAME_RE,
|
||||
REDACTED,
|
||||
buildClaudeMcpAddArgv,
|
||||
buildCodexMcpAddArgv,
|
||||
buildOpencodeMcpAddArgv,
|
||||
cmdString,
|
||||
isValidName,
|
||||
issuerFromMcpUrl,
|
||||
normalizeMcpUrl,
|
||||
redactToken,
|
||||
shellQuote,
|
||||
validateToken,
|
||||
} from '../core/mcp-registration.ts';
|
||||
|
||||
export const ENV_VAR = 'GBRAIN_REMOTE_TOKEN';
|
||||
// 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,
|
||||
buildOpencodeMcpAddArgv,
|
||||
cmdString,
|
||||
isLinkLocalOrMetadata,
|
||||
issuerFromMcpUrl,
|
||||
isValidName,
|
||||
normalizeMcpUrl,
|
||||
redactToken,
|
||||
validateToken,
|
||||
type TokenValidation,
|
||||
type UrlResult,
|
||||
} from '../core/mcp-registration.ts';
|
||||
|
||||
// Defined from the writer's exported constant so the printed interpolation and
|
||||
// the ownership fingerprint literal ({env:GBRAIN_REMOTE_TOKEN}) cannot drift.
|
||||
export const ENV_VAR = GBRAIN_REMOTE_TOKEN_ENV;
|
||||
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;
|
||||
|
||||
export type AgentId = 'claude-code' | 'codex' | 'perplexity' | 'generic';
|
||||
export type AgentId = 'claude-code' | 'codex' | 'opencode' | 'perplexity' | 'generic';
|
||||
|
||||
interface AgentSpec {
|
||||
id: AgentId;
|
||||
label: string; // human label for messages
|
||||
binary?: string; // CLI binary backing --install ('claude' | 'codex')
|
||||
binary?: string; // CLI binary backing --install ('claude' | 'codex'; opencode installs via the direct JSONC writer)
|
||||
installable: boolean;
|
||||
supportsOAuth: boolean; // accepts OAuth client-credentials connector fields
|
||||
}
|
||||
@@ -60,11 +107,14 @@ interface AgentSpec {
|
||||
export const AGENT_SPECS: Record<AgentId, AgentSpec> = {
|
||||
'claude-code': { id: 'claude-code', label: 'Claude Code', binary: 'claude', installable: true, supportsOAuth: false },
|
||||
codex: { id: 'codex', label: 'Codex', binary: 'codex', installable: true, supportsOAuth: false },
|
||||
// No `binary`: the opencode --install lane never execs a CLI (direct JSONC
|
||||
// write), and it branches before the exec lane's `spec.binary` read.
|
||||
opencode: { id: 'opencode', label: 'opencode', installable: true, supportsOAuth: false },
|
||||
perplexity: { id: 'perplexity', label: 'Perplexity Computer', installable: false, supportsOAuth: true },
|
||||
generic: { id: 'generic', label: 'your agent', installable: false, supportsOAuth: true },
|
||||
};
|
||||
|
||||
export const AGENT_IDS: AgentId[] = ['claude-code', 'codex', 'perplexity', 'generic'];
|
||||
export const AGENT_IDS: AgentId[] = ['claude-code', 'codex', 'opencode', 'perplexity', 'generic'];
|
||||
|
||||
// The named tools MUST be real MCP-exposed ops (verified by the round-trip
|
||||
// E2E). `capture` is intentionally absent: it's a CLI-only convenience wrapper,
|
||||
@@ -97,7 +147,7 @@ Usage:
|
||||
gbrain connect <mcp-url> [--token <bearer>] [flags]
|
||||
|
||||
Prints a copy-paste setup block for your agent, or wires it up directly with
|
||||
--install (claude-code + codex only). The MCP URL is your remote
|
||||
--install (claude-code, codex + opencode). The MCP URL is your remote
|
||||
'gbrain serve --http' endpoint; a bare host is rejected — pass an explicit
|
||||
https:// URL.
|
||||
|
||||
@@ -110,14 +160,15 @@ Auth:
|
||||
Flags:
|
||||
--token <bearer> Bearer token (else $${ENV_VAR}; from 'gbrain auth create')
|
||||
--name <id> MCP server name in the agent (default: ${DEFAULT_NAME})
|
||||
--agent <kind> claude-code (default) | codex | perplexity | generic
|
||||
--agent <kind> claude-code (default) | codex | opencode | perplexity | generic
|
||||
--oauth Use OAuth client credentials instead of a bearer token
|
||||
--register With --oauth: mint a client on the host (gbrain auth register-client)
|
||||
--client-id <id> With --oauth: use an existing OAuth client id
|
||||
--client-secret <s> With --oauth: use an existing OAuth client secret
|
||||
--scopes "<s>" With --oauth --register: client scopes (default: "${DEFAULT_SCOPES}")
|
||||
--install Run the agent's MCP-add command, then smoke-test the token
|
||||
(claude-code + codex only)
|
||||
(claude-code + codex + opencode; opencode installs via a direct
|
||||
config write — no binary needed, token stays out of the file)
|
||||
--yes Skip the install confirmation prompt
|
||||
--force On --install, replace an existing server of the same name
|
||||
--json Emit machine-readable JSON (secret redacted)
|
||||
@@ -128,114 +179,17 @@ Examples:
|
||||
gbrain connect https://brain.example.com/mcp --token gbrain_xxx
|
||||
gbrain connect https://brain.example.com:3131 --install --yes
|
||||
gbrain connect https://brain.example.com/mcp --token gbrain_xxx --agent codex
|
||||
gbrain connect https://brain.example.com/mcp --token gbrain_xxx --agent opencode --install
|
||||
gbrain connect https://brain.example.com/mcp --agent perplexity --oauth --register
|
||||
gbrain connect https://brain.example.com/mcp --agent perplexity --oauth \\
|
||||
--client-id gbrain_cl_xxx --client-secret gbrain_cs_xxx
|
||||
`;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 +209,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;
|
||||
@@ -330,6 +247,31 @@ function codexBlock(p: { name: string; url: string; token: string | null }): str
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function opencodeBlock(p: { name: string; url: string; token: string | null }): string {
|
||||
const tokenValue = p.token ?? PLACEHOLDER_TOKEN;
|
||||
const cmd = cmdString('opencode', buildOpencodeMcpAddArgv({ name: p.name, url: p.url, envVar: ENV_VAR }));
|
||||
const lines = [
|
||||
'# Paste into opencode:',
|
||||
'',
|
||||
'Connect my knowledge brain, then learn what it can do:',
|
||||
'',
|
||||
` export ${ENV_VAR}=${shellQuote(tokenValue)}`,
|
||||
` ${cmd}`,
|
||||
'',
|
||||
];
|
||||
if (!p.token) lines.push(`Replace ${PLACEHOLDER_TOKEN} with a token from \`gbrain auth create "opencode"\` on the host.`, '');
|
||||
lines.push(
|
||||
`The config stores the literal \`{env:${ENV_VAR}}\` interpolation — opencode resolves it at read time, ` +
|
||||
`so keep that variable exported in your shell profile; the token never lands in the config file. ` +
|
||||
`Restart opencode after registering (config is read at session start).`,
|
||||
'',
|
||||
LEARN_INSTRUCTION,
|
||||
'',
|
||||
SECRET_NOTE,
|
||||
);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function perplexityBearerBlock(p: { url: string; token: string | null }): string {
|
||||
const tokenValue = p.token ?? PLACEHOLDER_TOKEN;
|
||||
return [
|
||||
@@ -401,6 +343,7 @@ export function buildConnectBlock(p: { agent: AgentId; name: string; url: string
|
||||
switch (p.agent) {
|
||||
case 'claude-code': return claudeBlock(p);
|
||||
case 'codex': return codexBlock(p);
|
||||
case 'opencode': return opencodeBlock(p);
|
||||
case 'perplexity': return perplexityBearerBlock(p);
|
||||
case 'generic': return genericBearerBlock(p);
|
||||
}
|
||||
@@ -435,6 +378,10 @@ export function buildJson(p: { url: string; name: string; agent: AgentId; token:
|
||||
// Codex command carries no token (env-var name only), so it's safe verbatim.
|
||||
command_argv = buildCodexMcpAddArgv({ name: p.name, url: p.url, envVar: ENV_VAR });
|
||||
command = cmdString('codex', command_argv);
|
||||
} else if (p.agent === 'opencode') {
|
||||
// The literal {env:VAR} interpolation, not a token — safe verbatim.
|
||||
command_argv = buildOpencodeMcpAddArgv({ name: p.name, url: p.url, envVar: ENV_VAR });
|
||||
command = cmdString('opencode', command_argv);
|
||||
}
|
||||
return {
|
||||
schema_version: 1,
|
||||
@@ -468,6 +415,18 @@ export interface ConnectDeps {
|
||||
probe(url: string, token: string, timeoutMs: number): Promise<ConnectProbeResult>;
|
||||
env(name: string): string | undefined;
|
||||
registerOAuthClient(name: string, scopes: string): RegisterResult;
|
||||
/** opencode --install lane: direct JSONC write of a remote entry carrying
|
||||
* the literal `{env:GBRAIN_REMOTE_TOKEN}` interpolation (no binary execed,
|
||||
* no token on disk). Throws on a foreign same-name entry; an OURS entry at
|
||||
* a different url refuses unless `allowReplaceOtherSource` (connect maps
|
||||
* --force onto it). May be async: the default impl serializes on the
|
||||
* config-dir bootstrap lock (the writer contract); sync test fakes remain
|
||||
* assignable. */
|
||||
writeOpencodeRemoteEntry(
|
||||
name: string,
|
||||
url: string,
|
||||
opts?: { allowReplaceOtherSource?: boolean },
|
||||
): { configPath: string; replacedPrior: boolean } | Promise<{ configPath: string; replacedPrior: boolean }>;
|
||||
}
|
||||
|
||||
async function defaultPromptYesNo(question: string): Promise<boolean> {
|
||||
@@ -525,6 +484,31 @@ const defaultDeps: ConnectDeps = {
|
||||
probe: (url, token, timeoutMs) => probeBrainIdentity(url, token, { timeoutMs }),
|
||||
env: (name) => process.env[name],
|
||||
registerOAuthClient: defaultRegisterOAuthClient,
|
||||
writeOpencodeRemoteEntry: async (name, url, opts) => {
|
||||
// The writer's contract: callers hold acquireBootstrapLock on the config
|
||||
// dir (harness.ts [X11] parity) — the user-global file is shared across
|
||||
// workspaces and homes, so concurrent gbrain writers serialize here.
|
||||
const configPath = opencodeGlobalConfigPath();
|
||||
const cfgDir = dirname(configPath);
|
||||
mkdirSync(cfgDir, { recursive: true }); // the lock needs the dir; the writer mkdirs later anyway
|
||||
const lock = await acquireBootstrapLock(cfgDir);
|
||||
try {
|
||||
// Two-filename merge blind spot: opencode merges BOTH user-global
|
||||
// filenames, so a same-name gbrain entry in the SIBLING file would
|
||||
// survive this write as a shadow registration (ours → removed with a
|
||||
// note; foreign → refuse loudly naming both files).
|
||||
const sib = reconcileOpencodeSiblingGlobal(configPath, name, { url });
|
||||
for (const note of sib.notes) console.error(note);
|
||||
const r = writeOpencodeMcpEntry(
|
||||
configPath,
|
||||
{ kind: 'remote', name, url, tokenMode: 'env' },
|
||||
{ expect: { url }, ...(opts?.allowReplaceOtherSource ? { allowReplaceOtherSource: true } : {}) },
|
||||
);
|
||||
return { configPath: r.configPath, replacedPrior: r.replacedPrior };
|
||||
} finally {
|
||||
lock.release();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -700,8 +684,52 @@ export async function runConnect(args: string[], deps: ConnectDeps = defaultDeps
|
||||
// --install path. token is guaranteed literal here (install mode resolveToken).
|
||||
const realToken = token as string;
|
||||
if (!spec.installable) {
|
||||
fail(`--install supports claude-code and codex. ${spec.label} is set up through its own UI — drop --install to print the setup steps.`);
|
||||
fail(`--install supports claude-code, codex, and opencode. ${spec.label} is set up through its own UI — drop --install to print the setup steps.`);
|
||||
}
|
||||
|
||||
if (f.agent === 'opencode') {
|
||||
// Direct-writer lane: no opencode binary required (the JSONC write IS the
|
||||
// registration), and the config carries only the {env:VAR} interpolation
|
||||
// — the writer's fingerprint handles idempotent re-runs and refuses a
|
||||
// foreign same-name entry (--force cannot override THAT; pick --name).
|
||||
// --force maps to the writer's allowReplaceOtherSource so an OURS entry
|
||||
// at an old url (a rotated serve) is replaceable, mirroring the exec
|
||||
// lanes' documented --force semantics.
|
||||
if (!f.yes) {
|
||||
if (!deps.isTTY()) {
|
||||
fail('--install in a non-interactive shell requires --yes (refusing to register a credential-bearing MCP server without confirmation).');
|
||||
}
|
||||
const ok = await deps.promptYesNo(`Add MCP entry '${f.name}' -> ${url} to the opencode user-global config?`);
|
||||
if (!ok) fail('Aborted.');
|
||||
}
|
||||
let w: { configPath: string; replacedPrior: boolean };
|
||||
try {
|
||||
w = await deps.writeOpencodeRemoteEntry(f.name, url, { allowReplaceOtherSource: f.force });
|
||||
} catch (e) {
|
||||
fail(redactToken((e as Error).message, realToken));
|
||||
}
|
||||
console.error(
|
||||
`Added MCP entry '${f.name}' -> ${url} in ${w.configPath}` +
|
||||
`${w.replacedPrior ? ' (replaced the prior gbrain entry)' : ''}. Restart opencode (config is read at session start).`,
|
||||
);
|
||||
if (deps.env(ENV_VAR) !== realToken) {
|
||||
console.error(`opencode resolves {env:${ENV_VAR}} at read time. Add this to your shell profile so sessions can reach the brain:`);
|
||||
console.error(` export ${ENV_VAR}=<your-token>`);
|
||||
}
|
||||
const ocProbe = await deps.probe(url, realToken, f.timeoutMs);
|
||||
if (ocProbe.ok) {
|
||||
console.error(`Verified: ${ocProbe.identity || 'brain reachable'}`);
|
||||
console.error('');
|
||||
console.error(LEARN_INSTRUCTION);
|
||||
return;
|
||||
}
|
||||
console.error(
|
||||
`Warning: registered '${f.name}', but the smoke-test did not verify (${ocProbe.reason}): ${redactToken(ocProbe.message, realToken)}`,
|
||||
);
|
||||
console.error('The agent will likely hit 401/errors until the token or URL is fixed.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const binary = spec.binary as string; // 'claude' | 'codex'
|
||||
if (!deps.hasBinary(binary)) {
|
||||
fail(`${spec.label} CLI ('${binary}') not found on PATH. Install ${spec.label}, or drop --install to print the command to run manually.`);
|
||||
|
||||
+461
-28
@@ -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 5–20s 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.
|
||||
@@ -6313,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 5–20s): 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 5–20s)…');
|
||||
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.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8336,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
|
||||
@@ -8414,11 +8774,84 @@ export async function bootstrapDoctorChecks(engine: BrainEngine | null): Promise
|
||||
const pushStatuses = readPushStatuses();
|
||||
const statusFilesOnDisk = pushStatusFilesExist();
|
||||
const heartbeatFile = join(home, 'integrations', 'hooks', 'heartbeat.jsonl');
|
||||
const hasBootstrapState = receipt !== null || statusFilesOnDisk || 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;
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Spend-gate constants for `gbrain dream retriage` (#4152, outside-voice C12).
|
||||
* Split from dream-retriage.ts so tests can pin them without importing the
|
||||
* command's engine-bearing module graph. The chars→tokens ratio lives in
|
||||
* synthesize.ts (`CHARS_PER_TOKEN`, exported) — the command imports it from
|
||||
* there so the two estimates can't drift.
|
||||
*/
|
||||
|
||||
/** Estimated sweeps above this ask for confirmation unless --yes. */
|
||||
export const SPEND_CONFIRM_USD = 5;
|
||||
|
||||
/** When the model has no CANONICAL_PRICING entry, gate on file count instead. */
|
||||
export const UNPRICED_CONFIRM_FILES = 500;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user