v0.41.20.0 feat: gbrain status + doctor --scope=brain (fix wave 2: items #6 + #7) (#1544)

* feat(doctor): doctor-categories foundation — BRAIN/SKILL/OPS/META sets + drift guard

Categorizes every doctor check name into exactly one of four categories. Exported
constants + categorizeCheck(name) helper are the single source of truth for the
v0.41.20.0 brain_checks_score + category_scores + --scope=brain wave. Drift guard
test parses doctor.ts source for both inline {name: 'foo'} and helper
const name = 'foo' patterns; CI fires if any check name lacks a category.

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

* feat(doctor): brain_checks_score + category_scores + --scope=brain skip-computation

Extends Check with optional category and DoctorReport with brain_checks_score +
category_scores (additive — schema_version stays at 2; back-compat health_score
math byte-identical). buildChecks gains --scope=brain with explicit early-skip
gates around the SKILL check group (resolver_health + skill_conformance +
skill_brain_first + whoknows_health). Sub-second doctor on a brain with thousands
of skills. computeDoctorReport tags every check via categorizeCheck() at compute
time. Human output leads with the brain figure and renders the weighted
BrainHealth.brain_score alongside.

Test seam fix in test/doctor-home-dir-in-worktree.test.ts: the pre-existing
fragile JSON parser walked back from "checks" to find the envelope's outer
brace; v0.41.20.0's new nested category_scores object broke that heuristic.
Anchored on the canonical {"schema_version" envelope prefix instead.

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

* feat(status): gbrain status — single-screen brain health dashboard + get_status_snapshot MCP op

NEW gbrain status command (src/commands/status.ts) composes 6 sections:
sync (per-source last_sync_at + staleness via buildSyncStatusReport),
cycle (TWO rows: last autopilot-cycle + last autopilot-* of any kind —
reflects v0.36.4.0 health-aware autopilot's targeted handler routing;
totals read from result.report.totals per the canonical handler shape),
locks (gbrain_cycle_locks active rows), workers (readSupervisorEvents +
summarizeCrashes), queue (LIVE counts NO time-window — old stuck jobs
are exactly what status surfaces), autopilot (PID liveness via kill -0).

Stable --json envelope (schema_version: 1). Exit codes 0=ok / 1=snapshot
failed / 2=usage. --section filter.

Thin-client mode routes Sync + Cycle through NEW get_status_snapshot MCP
op (admin scope, NOT localOnly; payload deliberately omits Locks /
Workers / Queue / Autopilot so feature creep can't quietly widen the
admin-scoped data exposure). Local-only sections render "local-only —
N/A on remote brain" honestly instead of pretending the local install's
empty state is the remote brain's.

CLI dispatch: pre-engine-bind branch for thin-client (no PGLite needed)
+ engine-connected dispatch case for local mode. CLI-only architecture
per codex MAJOR-4 (status owns its own thin-client branch inside
runStatus, not routed through op dispatch).

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

* chore: bump version to v0.41.20.0 + CHANGELOG + TODOS + llms regen

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

* fix(doctor-categories): categorize batch_retry_health from v0.41.19.0 Supavisor wave

The drift guard correctly caught a new check introduced by master's v0.41.19.0
Supavisor Retry Cathedral (PR #1537). batch_retry_health surfaces batch-write
retry events from the new src/core/audit/batch-retry-audit.ts module — OPS
category (infrastructure liveness).

This is exactly why the drift guard exists: any future check added to doctor.ts
without a category entry fails CI immediately instead of silently degrading
to 'meta'.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-05-26 23:47:05 -07:00
committed by GitHub
co-authored by Claude Opus 4.7
parent a7b79b66d4
commit a74e5d90fc
16 changed files with 2053 additions and 48 deletions
+155
View File
@@ -2,6 +2,161 @@
All notable changes to GBrain will be documented in this file.
## [0.41.20.0] - 2026-05-26
**One command tells you if your brain is healthy. And `gbrain doctor`
no longer says your brain is broken when it's actually your skills
folder that needs attention.**
Two friction items, both about restoring signal-to-noise. Until this
release: there was no single command to answer "is my brain working?"
You ran `gbrain sources status`, `gbrain stats`, `gbrain jobs
supervisor status`, `gbrain jobs list`, and tailed audit logs.
Separately, `gbrain doctor` would tell you your brain health was 15/100
when the actual brain was fine — the score was being dragged down by
504 RESOLVER.md warnings from an OpenClaw skills tree.
After this release: `gbrain status` shows sync, last cycle, locks,
workers, queue, and autopilot state on one screen. `gbrain doctor`
leads with a brain-only figure and renders skill/ops/meta scores
alongside, so a polluted overall score doesn't lie to you about your
actual data.
What you can do that you couldn't before:
- `gbrain status` — single-screen dashboard. Per-source sync
freshness, last full cycle vs last targeted run, active locks,
worker health, live queue depth (including OLD stuck jobs — the
whole point of surfacing them), autopilot daemon liveness.
- `gbrain status --json` — stable monitoring envelope
(`schema_version: 1`). Exit codes 0=ok, 1=snapshot-failed, 2=usage.
- `gbrain status --section sync` — drill into one section.
- `gbrain status` against a thin-client install — Sync + Cycle route
through a new admin-scope `get_status_snapshot` MCP op; local-only
sections render `local-only — N/A on remote brain` instead of
pretending the local install's empty state is the remote brain's.
- `gbrain doctor --scope=brain` — sub-second on a brain with thousands
of skills. Skips computation of the SKILL check group (resolver
walk, skill conformance scan, brain-first audit, whoknows health)
entirely. Real skip, not just an output filter.
- `gbrain doctor` JSON envelope adds `brain_checks_score` and
`category_scores` (brain/skill/ops/meta), each computed with the
same penalty math (100 20×fails 5×warns) restricted to the
named category. The legacy `health_score` field is unchanged and
remains byte-identical for any fixed check set (back-compat for
every existing MCP consumer + CI gate).
- Doctor human output leads with the brain figure, then shows
skill/ops/meta subscores and the existing weighted
`BrainHealth.brain_score` alongside, so operators see both lenses
without confusion.
The two scores are intentionally orthogonal:
- `BrainHealth.brain_score` — weighted data-composition health
(35/25/15/15/10). "How healthy is my brain's data?"
- `brain_checks_score` — penalty over brain-category check failures.
"How many of the brain-category doctor checks failed?"
## To take advantage of v0.41.20.0
`gbrain upgrade` should do this automatically. Try:
```bash
gbrain status # see the dashboard
gbrain doctor --scope=brain # brain-only doctor, sub-second
gbrain doctor --json | jq .category_scores
gbrain doctor --json | jq .brain_checks_score
```
If any step fails or the numbers look wrong, please file an issue at
https://github.com/garrytan/gbrain/issues with the output of
`gbrain doctor` + the contents of `~/.gbrain/upgrade-errors.jsonl` if
it exists.
### Itemized changes
- **`gbrain status` (NEW command).** `src/commands/status.ts`
orchestrates 6 section renderers (sync, cycle, locks, workers,
queue, autopilot). Composes existing primitives —
`buildSyncStatusReport` from sync.ts, `readSupervisorEvents` +
`summarizeCrashes` from `supervisor-audit.ts`, direct queries
against `gbrain_cycle_locks` and `minion_jobs`, `kill -0` probe of
`~/.gbrain/autopilot.lock`. No new schema, no new persistence.
Cycle section shows TWO rows ("Last full cycle" = latest
`autopilot-cycle` row, "Last targeted run" = latest `autopilot-*`
row of any kind) to reflect v0.36.4.0's health-aware autopilot
where targeted handlers fire most ticks and a full cycle every
~60min. Cycle totals are read from `result.report.totals` (the
canonical handler output shape). Queue counts are LIVE (no
time-window filter) so old stuck `waiting` / `active` jobs surface
— that's the failure mode this dashboard exists to expose.
- **`get_status_snapshot` MCP op (NEW).** `src/core/operations.ts`.
Admin scope (NOT localOnly). Payload:
`{schema_version: 1, sync, cycle}` only. Locks / Workers / Queue /
Autopilot deliberately omitted from the remote payload — they're
local-host concerns; the local CLI renders them as "N/A on remote
brain" in thin-client mode instead of pretending the local
install's state is the remote brain's. Admin-scoped from day one
to prevent future feature creep (adding locks/workers counters)
from quietly widening the data exposed under a read-scoped token.
- **`src/core/doctor-categories.ts` (NEW).** Single source of truth
for check categorization. Four exported sets
(BRAIN/SKILL/OPS/META) cover every check name in
`src/commands/doctor.ts` today. The `categorizeCheck(name)` helper
maps unknown names to `'meta'` with a once-per-process stderr warn
so drift surfaces in dev runs before CI catches it.
- **Doctor extensions.** `src/commands/doctor.ts` extends `Check`
with an optional `category?` field and `DoctorReport` with
`brain_checks_score` + `category_scores` (additive —
`schema_version` stays at 2). `computeDoctorReport` tags each check
via `categorizeCheck()` at compute time. `buildChecks` accepts a
`--scope=brain` arg that gates the SKILL check group behind
explicit early-skip branches at each call site (resolver walk,
skill conformance, skill brain-first audit, whoknows health).
Human output leads with the brain figure and renders the weighted
`BrainHealth.brain_score` alongside.
- **`gbrain doctor --remediate` UNCHANGED.**
`src/core/brain-score-recommendations.ts` is not touched. Codex's
outside-voice review caught that `--remediate` already correctly
reads `engine.getHealth().brain_score` (the weighted 35/25/15/15/10
composite), NOT the doctor's polluted `health_score`. Switching it
to a new field would have replaced a weighted brain metric with a
coarser category counter — a regression the review caught before
any code was written.
- **CLI dispatch.** `src/cli.ts` registers `status` in `CLI_ONLY` and
adds two dispatch sites: a pre-engine-bind branch for thin-client
mode (Sync + Cycle via MCP op, no PGLite needed) and a normal
engine-connected dispatch case for local mode. Architecture:
status is CLI-only with its own thin-client branch inside
`runStatus` (not routed through op dispatch), the only
architecture that honestly composes local-only sections.
- **Tests (7 new files).**
- `test/doctor-categories.test.ts` — drift guard: every check name
in doctor.ts source is categorized exactly once; CI fires loudly
on missed additions. Catches both `name: 'foo'` inline form and
`const name = 'foo'` helper-function form.
- `test/doctor-brain-checks-score.test.ts` — back-compat
byte-identity on `health_score` for a fixed check set; new field
math; renaming regression that asserts NO `brain_health_score`
field ships (it's `brain_checks_score` to avoid colliding with
the weighted `BrainHealth.brain_score`).
- `test/doctor-scope-filter.test.ts``--scope=brain` skips
computation of SKILL group entirely; observable absence of
`resolver_health` / `skill_conformance` / `skill_brain_first` /
`whoknows_health` from the returned checks list.
- `test/status-sections.test.ts``parseSectionFlag` validation +
exit code policy + runStatus shape.
- `test/get-status-snapshot-op.test.ts` — op definition pins
(scope is admin, localOnly false, payload omits
Locks/Workers/Queue/Autopilot).
- `test/e2e/status-pglite.test.ts` — full PGLite E2E with seeded
minion_jobs rows, gbrain_cycle_locks rows, and supervisor audit.
Asserts dual cycle rows, totals from `result.report.totals`,
live queue counts (no time window).
- `test/doctor-home-dir-in-worktree.test.ts` (UPDATED) — fixed the
pre-existing fragile JSON parser to anchor on the canonical
`{"schema_version"` envelope prefix instead of walking back from
`"checks"` (which broke once `category_scores` introduced a
nested object between).
## [0.41.19.0] - 2026-05-26
**Your dream cycle stops silently losing wiki links.**
+44
View File
@@ -1,5 +1,49 @@
# TODOS
## v0.41.19.0 status + doctor-categories wave follow-ups (v0.42+)
- **TODO-V19-A (P3)**: Persistent `cycle_runs` table. v0.41.19.0 infers
"last full cycle" by querying `minion_jobs WHERE name = 'autopilot-cycle'`
for the most recent completed row. This works but conflates "cycle ran
via the autopilot scheduler" with "cycle ran." A dedicated `cycle_runs`
table written from `runCycle` directly would let `gbrain status`
surface manual `gbrain dream` invocations + per-source partial cycles
separately. Defer until the inference's accuracy limits actually bite
someone.
- **TODO-V19-B (P2)**: Surface `extract_atoms` + `synthesize_concepts`
counts in `CycleReport.totals` top-level. Today the counts live inside
each phase's `details` field; the v0.41.19.0 `gbrain status` cycle
section can't surface them without per-phase parsing. Bump the
`CycleReport.totals` shape additively (the existing field is
documented as additive) and add `atoms_inserted` +
`concepts_inserted` next to `facts_consolidated`.
- **TODO-V19-C (P3)**: Check-registry refactor for `gbrain doctor`. The
v0.41.19.0 `--scope=brain` uses explicit early-skip gates inline at
each call site (~40 LOC across resolver + skill_conformance +
skill_brain_first + whoknows). If we want to add more scope
dimensions later (e.g. `--scope=ops`, `--exclude-skill`), the right
next step is a check registry: each check declares
`{name, category, run}`, `buildChecks` becomes "run all entries
whose category is in scope." ~300 LOC, touches every check site.
Considered + rejected for v0.41.19.0 as too large for a single fix
wave (D9-B option in the plan).
- **TODO-V19-D (P3)**: Read installed launchd/cron/systemd schedule
to compute a real "next autopilot tick" timestamp. v0.41.19.0
status surfaces "Autopilot: running (PID N)" instead. Cross-OS
scheduler probing is a separate, larger problem; macOS launchd
plist parsing alone is ~80 LOC.
- **TODO-V19-E (P2)**: Apply category-aware exit codes to
`gbrain doctor`. Today doctor exits 0 on all-ok, 1 on any fail.
After categorization, a CI gate could opt into "fail only on
brain-category failures" via `--scope=brain` (already shipping) or
a `--fail-on=brain` flag. Filing this as a discoverability
follow-up — the `--scope=brain` flag already covers most of the
use case.
## v0.41.18.0 onboard wave follow-ups (v0.42.1+)
- **TODO-A (P2)**: Pack-aware `linkable: boolean` per-type field on schema-pack
+1 -1
View File
@@ -1 +1 @@
0.41.19.0
0.41.20.0
+1 -1
View File
@@ -140,5 +140,5 @@
"bun": ">=1.3.10"
},
"license": "MIT",
"version": "0.41.19.0"
"version": "0.41.20.0"
}
+26 -1
View File
@@ -35,7 +35,7 @@ for (const op of operations) {
}
// CLI-only commands that bypass the operation layer
const CLI_ONLY = new Set(['init', 'reinit-pglite', 'upgrade', 'post-upgrade', 'check-update', 'integrations', 'publish', 'check-backlinks', 'lint', 'report', 'import', 'export', 'files', 'embed', 'serve', 'call', 'config', 'doctor', 'migrate', 'eval', 'sync', 'extract', 'extract-conversation-facts', 'features', 'autopilot', 'graph-query', 'jobs', 'agent', 'apply-migrations', 'skillpack-check', 'skillpack', 'resolvers', 'integrity', 'repair-jsonb', 'orphans', 'sources', 'mounts', 'dream', 'check-resolvable', 'routing-eval', 'skillify', 'smoke-test', 'providers', 'storage', 'repos', 'code-def', 'code-refs', 'reindex', 'reindex-code', 'reindex-frontmatter', 'code-callers', 'code-callees', 'frontmatter', 'auth', 'friction', 'claw-test', 'book-mirror', 'takes', 'think', 'salience', 'anomalies', 'transcripts', 'models', 'remote', 'recall', 'forget', 'edges-backfill', 'cache', 'ze-switch', 'founder', 'brainstorm', 'lsd', 'schema', 'capture', 'onboard', 'conversation-parser']);
const CLI_ONLY = new Set(['init', 'reinit-pglite', 'upgrade', 'post-upgrade', 'check-update', 'integrations', 'publish', 'check-backlinks', 'lint', 'report', 'import', 'export', 'files', 'embed', 'serve', 'call', 'config', 'doctor', 'migrate', 'eval', 'sync', 'extract', 'extract-conversation-facts', 'features', 'autopilot', 'graph-query', 'jobs', 'agent', 'apply-migrations', 'skillpack-check', 'skillpack', 'resolvers', 'integrity', 'repair-jsonb', 'orphans', 'sources', 'mounts', 'dream', 'check-resolvable', 'routing-eval', 'skillify', 'smoke-test', 'providers', 'storage', 'repos', 'code-def', 'code-refs', 'reindex', 'reindex-code', 'reindex-frontmatter', 'code-callers', 'code-callees', 'frontmatter', 'auth', 'friction', 'claw-test', 'book-mirror', 'takes', 'think', 'salience', 'anomalies', 'transcripts', 'models', 'remote', 'recall', 'forget', 'edges-backfill', 'cache', 'ze-switch', 'founder', 'brainstorm', 'lsd', 'schema', 'capture', 'onboard', 'conversation-parser', 'status']);
// CLI-only commands whose handlers print their own --help text. These are
// excluded from the generic short-circuit so detailed per-command and
// per-subcommand usage stays reachable.
@@ -1175,6 +1175,20 @@ async function handleCliOnly(command: string, args: string[]) {
}
}
// v0.41.19.0: `gbrain status` on thin-client installs bypasses connectEngine
// entirely — Sync + Cycle route through the `get_status_snapshot` MCP op,
// and local-only sections render as "N/A on remote brain". Local mode falls
// through to the engine-connected dispatch path below. (`args` here is the
// subArgs slice already — no need to re-slice past the command.)
if (command === 'status') {
const cfgPre = loadConfig();
if (cfgPre && isThinClient(cfgPre)) {
const { runStatus } = await import('./commands/status.ts');
const result = await runStatus(null, args);
process.exit(result.exitCode);
}
}
// v0.37 fix wave (Lane D.4 + CDX2-12): short-circuit `gbrain sync --help`
// BEFORE the engine bind. runSync has its own --help branch but can't
// reach it without an engine — which means a user running `--help` from
@@ -1422,6 +1436,17 @@ async function handleCliOnly(command: string, args: string[]) {
await runAnomalies(engine, args);
break;
}
// v0.41.19.0 — `gbrain status`: single-screen brain health dashboard.
// CLI-only with own thin-client branch INSIDE runStatus (per D2 + codex
// MAJOR-4 architecture). Composes existing exports: buildSyncStatusReport,
// readSupervisorEvents, gbrain_cycle_locks, minion_jobs.
case 'status': {
const { runStatus } = await import('./commands/status.ts');
const result = await runStatus(engine, args);
process.exit(result.exitCode);
// eslint-disable-next-line no-unreachable
break;
}
// v0.38 — Capture: single human-facing entrypoint for ingestion.
case 'capture': {
const { runCapture } = await import('./commands/capture.ts');
+148 -41
View File
@@ -20,6 +20,7 @@ import {
import { loadCompletedMigrations } from '../core/preferences.ts';
import { compareVersions } from './migrations/index.ts';
import { createProgress, startHeartbeat, type ProgressReporter } from '../core/progress.ts';
import { categorizeCheck, type CheckCategory } from '../core/doctor-categories.ts';
import { getCliOptions, cliOptsToProgressOptions } from '../core/cli-options.ts';
import type { DbUrlSource } from '../core/config.ts';
import { gbrainPath } from '../core/config.ts';
@@ -54,6 +55,12 @@ export interface Check {
remediation?: import('../core/remediation-step.ts').RemediationStep[];
/** Top-level triage state per D13. */
remediation_status?: 'remediable' | 'human_only' | 'blocked';
/**
* v0.41.19.0 category tag assigned by `categorizeCheck(name)` at report
* compute time. Optional + additive so legacy consumers ignore it.
* Source of truth: `src/core/doctor-categories.ts`.
*/
category?: CheckCategory;
}
/**
@@ -68,26 +75,92 @@ export interface Check {
export interface DoctorReport {
schema_version: 2;
status: 'healthy' | 'warnings' | 'unhealthy';
/**
* Legacy all-checks aggregate. `100 20×fails 5×warns`, floor 0.
*
* Preserved verbatim from pre-v0.41.19.0 for back-compat with `gbrain
* doctor --remediate`, `gbrain remote doctor`, the MCP `run_doctor` op,
* and any external monitor / CI gate that reads this field. NO behavior
* change: a fixed check set produces a byte-identical `health_score`
* before and after the v0.41.19.0 wave.
*/
health_score: number;
/**
* v0.41.19.0 same penalty math (100 20×fails 5×warns) restricted to
* checks tagged `category: 'brain'` by `categorizeCheck()`. The "is my
* brain's data healthy?" signal, decoupled from skill routing / ops /
* meta. Orthogonal to `BrainHealth.brain_score` (the weighted
* 35/25/15/15/10 composite surfaced by the `brain_score` doctor check)
* `brain_checks_score` counts brain-category check failures;
* `brain_score` measures brain-data composition. Doctor renders both.
*/
brain_checks_score: number;
/**
* v0.41.19.0 per-category penalty scores. Same math as `health_score`,
* restricted to each category in turn. An operator reading `score: 15`
* driven by 504 RESOLVER.md warnings now sees `category_scores.brain:
* ~100` and `category_scores.skill: 0` instead of one polluted number.
*/
category_scores: {
brain: number;
skill: number;
ops: number;
meta: number;
};
checks: Check[];
}
/**
* Compute the {status, health_score} headline from a list of checks.
* Mirrors the calculation in outputResults() so remote callers and the
* existing CLI front-end agree on what "healthy" means.
*/
export function computeDoctorReport(checks: Check[]): DoctorReport {
const hasFail = checks.some(c => c.status === 'fail');
const hasWarn = checks.some(c => c.status === 'warn');
function _penaltyScore(checks: Check[]): number {
let score = 100;
for (const c of checks) {
if (c.status === 'fail') score -= 20;
else if (c.status === 'warn') score -= 5;
}
score = Math.max(0, score);
return Math.max(0, score);
}
/**
* Compute the {status, health_score, brain_checks_score, category_scores}
* headline from a list of checks. Mirrors the calculation in outputResults()
* so remote callers and the existing CLI front-end agree on what "healthy"
* means.
*
* **Back-compat invariant:** `health_score` math is byte-identical to
* pre-v0.41.19.0 for any fixed `checks` array. The new fields are additive.
*
* **Categorization:** each check is tagged via `categorizeCheck(name)` at
* report-build time if it doesn't already carry a `category` field. The
* categorizer is the single source of truth in
* `src/core/doctor-categories.ts`.
*/
export function computeDoctorReport(checks: Check[]): DoctorReport {
const tagged = checks.map((c) =>
c.category ? c : { ...c, category: categorizeCheck(c.name) },
);
const hasFail = tagged.some((c) => c.status === 'fail');
const hasWarn = tagged.some((c) => c.status === 'warn');
const health_score = _penaltyScore(tagged);
const brain = tagged.filter((c) => c.category === 'brain');
const skill = tagged.filter((c) => c.category === 'skill');
const ops = tagged.filter((c) => c.category === 'ops');
const meta = tagged.filter((c) => c.category === 'meta');
const status: DoctorReport['status'] = hasFail ? 'unhealthy' : hasWarn ? 'warnings' : 'healthy';
return { schema_version: 2, status, health_score: score, checks };
return {
schema_version: 2,
status,
health_score,
brain_checks_score: _penaltyScore(brain),
category_scores: {
brain: _penaltyScore(brain),
skill: _penaltyScore(skill),
ops: _penaltyScore(ops),
meta: _penaltyScore(meta),
},
checks: tagged,
};
}
/**
@@ -2666,6 +2739,12 @@ export async function buildChecks(
const fastMode = args.includes('--fast');
const doFix = args.includes('--fix');
const dryRun = args.includes('--dry-run');
// v0.41.19.0 — `--scope=brain` SKIPS the SKILL check group (which walks the
// filesystem `skills/` tree, the dominant non-DB cost). Defaults to `all`.
// `runResolverChecks`-equivalent invocations are gated below; the same gate
// covers `whoknows_health` (the one DB-dependent skill check) where it's
// invoked later in the function.
const scope: 'all' | 'brain' = args.includes('--scope=brain') ? 'brain' : 'all';
const checks: Check[] = [];
let autoFixReport: AutoFixReport | null = null;
@@ -2678,16 +2757,26 @@ export async function buildChecks(
// --- Filesystem checks (always run, no DB needed) ---
// 1. Resolver health
// Use the same auto-detect as `check-resolvable` so doctor sees a
// workspace/skills dir reachable via $OPENCLAW_WORKSPACE or
// ~/.openclaw/workspace, not just a `skills/` walked up from cwd.
// Read-only variant adds the install-path fallback so a hosted-CLI install
// run from `~` (e.g., `bun install -g github:garrytan/gbrain && cd ~ &&
// gbrain doctor`) can still find the bundled skills/ dir without warning.
const detected = autoDetectSkillsDirReadOnly();
// 1. Resolver health + 2. Skill conformance + 2b. Skill brain-first.
//
// SKILL check group (gated behind --scope=all).
//
// The resolver walk reads every SKILL.md under the configured skills dir
// (`skills/RESOLVER.md` or workspace-root `AGENTS.md`). On large OpenClaw
// deployments with 200+ skills this is the dominant non-DB cost. The
// v0.41.19.0 `--scope=brain` flag skips this whole block per D9 in the plan.
//
// We also skip `--fix` execution under scope=brain because --fix
// exclusively targets DRY violations inside SKILL.md files. Use the same
// auto-detect as `check-resolvable` so doctor sees a workspace/skills dir
// reachable via $OPENCLAW_WORKSPACE or ~/.openclaw/workspace, not just a
// `skills/` walked up from cwd. Read-only variant adds the install-path
// fallback so a hosted-CLI install run from `~` (e.g., `bun install -g
// github:garrytan/gbrain && cd ~ && gbrain doctor`) can still find the
// bundled skills/ dir without warning.
const detected = scope === 'all' ? autoDetectSkillsDirReadOnly() : { dir: null, source: 'none' as const };
const skillsDir = detected.dir;
if (skillsDir) {
if (scope === 'all' && skillsDir) {
// --fix: run auto-repair BEFORE checkResolvable so the post-fix scan
// reflects the new state. Auto-fix only targets DRY violations today;
@@ -2736,12 +2825,12 @@ export async function buildChecks(
};
checks.push(check);
}
} else {
} else if (scope === 'all') {
checks.push({ name: 'resolver_health', status: 'warn', message: 'Could not find skills directory' });
}
// 2. Skill conformance
if (skillsDir) {
// 2. Skill conformance (SKILL group — gated)
if (scope === 'all' && skillsDir) {
const conformanceResult = checkSkillConformance(skillsDir);
checks.push(conformanceResult);
}
@@ -2757,7 +2846,9 @@ export async function buildChecks(
// snapshot.json. Writes one detected/resolved JSONL line per state
// transition + one fixed line per applied --fix. Stable brain → zero
// audit writes per doctor run.
if (skillsDir) {
//
// SKILL group — gated.
if (scope === 'all' && skillsDir) {
checks.push(skillBrainFirstCheck(skillsDir));
}
@@ -4267,8 +4358,11 @@ export async function buildChecks(
// v0.33: whoknows_health — fixture presence + row count. The eval
// gate itself runs via `gbrain eval whoknows`; this check is the
// "did you do the assignment?" signal.
progress.heartbeat('whoknows_health');
checks.push(await whoknowsHealthCheck(engine));
// SKILL group — gated behind --scope=all (v0.41.19.0).
if (scope === 'all') {
progress.heartbeat('whoknows_health');
checks.push(await whoknowsHealthCheck(engine));
}
// v0.36 cross-modal wave: modality column cleanup.
//
@@ -5717,26 +5811,21 @@ export function skillBrainFirstCheck(skillsDir: string): Check {
}
function outputResults(checks: Check[], json: boolean): boolean {
const hasFail = checks.some(c => c.status === 'fail');
const hasWarn = checks.some(c => c.status === 'warn');
// Compute composite health score (0-100)
let score = 100;
for (const c of checks) {
if (c.status === 'fail') score -= 20;
else if (c.status === 'warn') score -= 5;
}
score = Math.max(0, score);
// v0.41.19.0 — render goes through computeDoctorReport so the human
// output, JSON output, and remote MCP envelope all share one shape.
const report = computeDoctorReport(checks);
const hasFail = report.status === 'unhealthy';
const hasWarn = report.status === 'warnings';
const score = report.health_score;
if (json) {
const status = hasFail ? 'unhealthy' : hasWarn ? 'warnings' : 'healthy';
console.log(JSON.stringify({ schema_version: 2, status, health_score: score, checks }));
console.log(JSON.stringify(report));
return hasFail;
}
console.log('\nGBrain Health Check');
console.log('===================');
for (const c of checks) {
for (const c of report.checks) {
const icon = c.status === 'ok' ? 'OK' : c.status === 'warn' ? 'WARN' : 'FAIL';
console.log(` [${icon}] ${c.name}: ${c.message}`);
if (c.issues) {
@@ -5747,12 +5836,30 @@ function outputResults(checks: Check[], json: boolean): boolean {
}
}
// v0.41.19.0 — brain-first headline. The user asked "is my brain ok?".
// Lead with the brain-category score; show the legacy aggregate
// alongside as context. The weighted BrainHealth.brain_score (data
// composition) is surfaced separately by the `brain_score` check above —
// it's read out of the check list so we don't duplicate the query.
const brainScoreCheck = report.checks.find((c) => c.name === 'brain_score');
const brainScoreLine = brainScoreCheck
? `Weighted brain score: ${brainScoreCheck.status === 'ok' ? '' : `[${brainScoreCheck.status.toUpperCase()}] `}${brainScoreCheck.message}`
: null;
console.log('');
console.log(`Brain checks: ${report.brain_checks_score}/100 (category penalty)`);
console.log(`Skill checks: ${report.category_scores.skill}/100`);
console.log(`Ops checks: ${report.category_scores.ops}/100`);
console.log(`Meta checks: ${report.category_scores.meta}/100`);
if (brainScoreLine) console.log(brainScoreLine);
console.log('');
if (hasFail) {
console.log(`\nHealth score: ${score}/100. Failed checks found.`);
console.log(`Overall health score: ${score}/100. Failed checks found.`);
} else if (hasWarn) {
console.log(`\nHealth score: ${score}/100. All checks OK (some warnings).`);
console.log(`Overall health score: ${score}/100. All checks OK (some warnings).`);
} else {
console.log(`\nHealth score: ${score}/100. All checks passed.`);
console.log(`Overall health score: ${score}/100. All checks passed.`);
}
return hasFail;
}
+604
View File
@@ -0,0 +1,604 @@
/**
* `gbrain status` — single-screen brain health dashboard.
*
* The command that answers "is my brain healthy and working?" without
* making operators run five other commands (gbrain sources status, gbrain
* stats, gbrain jobs supervisor status, gbrain jobs list, tail audit logs).
*
* Six sections:
* - Sync — per-source last_sync_at + staleness
* - Cycle — TWO rows: last FULL cycle (autopilot-cycle) +
* last TARGETED run (any autopilot-* job). Reflects
* v0.36.4.0's health-aware autopilot (healthy brains run
* targeted handlers most ticks, full cycle every ~60min).
* - Locks — active rows in gbrain_cycle_locks
* - Workers — supervisor health from the audit JSONL
* - Queue — live minion_jobs counts BY status (NO time window —
* old stuck jobs are exactly what status surfaces)
* - Autopilot — daemon PID liveness via kill -0 probe
*
* Exit codes (kubectl-style):
* 0 snapshot produced successfully (even if it carries warnings)
* 1 snapshot could NOT be produced (DB unreachable, fatal IO error)
* 2 usage error (bad --section value)
*
* Thin-client mode (isThinClient(cfg)):
* - Sync + Cycle route through `get_status_snapshot` MCP op (admin scope)
* - Locks/Workers/Queue/Autopilot render "local-only — N/A on remote brain"
* because they're host-local concerns; pretending the local install's
* local-host operational state is the remote brain's would lie to the
* operator.
*
* --json emits a stable envelope:
* { schema_version: 1, sync, cycle, locks?, workers?, queue?, autopilot? }
* Sections may be omitted (thin-client mode, --section filter, or
* section-build failure that didn't break the whole snapshot).
*/
import type { BrainEngine } from '../core/engine.ts';
import { existsSync, readFileSync } from 'node:fs';
import { gbrainPath, loadConfig, isThinClient } from '../core/config.ts';
import { callRemoteTool, unpackToolResult } from '../core/mcp-client.ts';
import {
buildSyncStatusReport,
type SyncStatusReport,
} from './sync.ts';
import {
readSupervisorEvents,
summarizeCrashes,
} from '../core/minions/handlers/supervisor-audit.ts';
const SCHEMA_VERSION = 1 as const;
const VALID_SECTIONS = ['sync', 'cycle', 'locks', 'workers', 'queue', 'autopilot'] as const;
type Section = (typeof VALID_SECTIONS)[number];
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface CycleRow {
finished_at: string | null;
name: string;
status: string;
duration_ms: number | null;
totals: Record<string, unknown> | null;
}
export interface CycleSnapshot {
/** Most recent fully-completed autopilot-cycle (9-phase full sweep). */
last_full: CycleRow | null;
/** Most recent autopilot-* job of any kind (full OR targeted). */
last_targeted: CycleRow | null;
}
export interface LockRow {
id: string;
holder_pid: number | null;
holder_host: string | null;
acquired_at: string | null;
ttl_expires_at: string | null;
}
export interface QueueCounts {
active: number;
waiting: number;
completed: number;
failed: number;
dead: number;
}
export interface WorkerSummary {
crashes_24h: number;
clean_exits_24h: number;
by_cause: Record<string, number>;
last_event_ts: string | null;
}
export interface AutopilotStatus {
installed: boolean;
lockfile_present: boolean;
pid: number | null;
running: boolean;
}
export interface StatusReport {
schema_version: typeof SCHEMA_VERSION;
generated_at: string;
mode: 'local' | 'thin-client';
sync?: SyncStatusReport;
cycle?: CycleSnapshot;
locks?: LockRow[] | { local_only_remote: true };
workers?: WorkerSummary | { local_only_remote: true };
queue?: QueueCounts | { local_only_remote: true };
autopilot?: AutopilotStatus | { local_only_remote: true };
warnings?: string[];
}
// ---------------------------------------------------------------------------
// Cycle section — composable, also called from the MCP op
// ---------------------------------------------------------------------------
/**
* Read the latest full cycle + latest targeted-run rows from `minion_jobs`.
*
* Read path is `result.report.totals` per codex MINOR-3 — the autopilot-cycle
* handler returns `{partial, status, report}` where `report.totals` carries
* the additive counters (synth_pages_written, patterns_written,
* facts_consolidated, pages_emotional_weight_recomputed, …).
*
* Exported for `src/core/operations.ts:get_status_snapshot` and for the
* E2E test fixture seed path.
*/
export async function buildCycleSnapshot(engine: BrainEngine): Promise<CycleSnapshot> {
type Row = {
finished_at: string | Date | null;
name: string;
status: string;
started_at: string | Date | null;
result: { partial?: unknown; status?: unknown; report?: { totals?: Record<string, unknown> } } | null;
};
const isoOrNull = (v: string | Date | null): string | null => {
if (!v) return null;
return v instanceof Date ? v.toISOString() : new Date(v).toISOString();
};
const durationMs = (started: string | Date | null, finished: string | Date | null): number | null => {
if (!started || !finished) return null;
const s = started instanceof Date ? started.getTime() : new Date(started).getTime();
const f = finished instanceof Date ? finished.getTime() : new Date(finished).getTime();
return Math.max(0, f - s);
};
const toCycleRow = (r: Row | undefined): CycleRow | null => {
if (!r) return null;
return {
finished_at: isoOrNull(r.finished_at),
name: r.name,
status: r.status,
duration_ms: durationMs(r.started_at, r.finished_at),
totals: r.result?.report?.totals ?? null,
};
};
let fullRow: Row | undefined;
let targetedRow: Row | undefined;
try {
const fullRows = await engine.executeRaw<Row>(
`SELECT finished_at, name, status, started_at, result
FROM minion_jobs
WHERE name = 'autopilot-cycle' AND status = 'completed'
ORDER BY finished_at DESC NULLS LAST
LIMIT 1`,
);
fullRow = fullRows[0];
} catch {
/* fall through — no row */
}
try {
const targetedRows = await engine.executeRaw<Row>(
`SELECT finished_at, name, status, started_at, result
FROM minion_jobs
WHERE name LIKE 'autopilot-%' AND status = 'completed'
ORDER BY finished_at DESC NULLS LAST
LIMIT 1`,
);
targetedRow = targetedRows[0];
} catch {
/* fall through */
}
return { last_full: toCycleRow(fullRow), last_targeted: toCycleRow(targetedRow) };
}
// ---------------------------------------------------------------------------
// Local-only sections
// ---------------------------------------------------------------------------
async function buildLocks(engine: BrainEngine): Promise<LockRow[]> {
type Row = {
id: string;
holder_pid: number | null;
holder_host: string | null;
acquired_at: string | Date | null;
ttl_expires_at: string | Date | null;
};
const iso = (v: string | Date | null) =>
v instanceof Date ? v.toISOString() : v ? new Date(v).toISOString() : null;
try {
const rows = await engine.executeRaw<Row>(
`SELECT id, holder_pid, holder_host, acquired_at, ttl_expires_at
FROM gbrain_cycle_locks
WHERE ttl_expires_at > NOW()
ORDER BY acquired_at`,
);
return rows.map((r) => ({
id: r.id,
holder_pid: r.holder_pid,
holder_host: r.holder_host,
acquired_at: iso(r.acquired_at),
ttl_expires_at: iso(r.ttl_expires_at),
}));
} catch {
return [];
}
}
async function buildQueueCounts(engine: BrainEngine): Promise<QueueCounts> {
type Row = { status: string; count: string | number };
const counts: QueueCounts = { active: 0, waiting: 0, completed: 0, failed: 0, dead: 0 };
try {
// Live counts, NO time window (codex MAJOR-6). Old stuck waiting/active
// jobs are the failure mode `gbrain status` should surface, not hide.
const rows = await engine.executeRaw<Row>(
`SELECT status, COUNT(*)::text AS count FROM minion_jobs GROUP BY status`,
);
for (const r of rows) {
const n = typeof r.count === 'string' ? parseInt(r.count, 10) : r.count;
if (r.status in counts) (counts as unknown as Record<string, number>)[r.status] = n;
}
} catch {
/* PGLite without minion_jobs or pre-migration brain — return zeros */
}
return counts;
}
function buildWorkerSummary(): WorkerSummary {
let crashes_24h = 0;
let clean_exits_24h = 0;
const by_cause: Record<string, number> = {};
let last_event_ts: string | null = null;
try {
const since = Date.now() - 24 * 60 * 60 * 1000;
const events = readSupervisorEvents({ sinceMs: since });
if (events.length > 0) {
last_event_ts = events[events.length - 1].ts;
}
const exitEvents = events.filter((e) => e.event === 'worker_exited');
const summary = summarizeCrashes(exitEvents);
crashes_24h = summary.total;
clean_exits_24h = summary.clean_exits;
Object.assign(by_cause, summary.by_cause);
} catch {
/* audit dir missing or unreadable — return zeros */
}
return { crashes_24h, clean_exits_24h, by_cause, last_event_ts };
}
function buildAutopilotStatus(): AutopilotStatus {
const lockPath = gbrainPath('autopilot.lock');
const lockfile_present = existsSync(lockPath);
let pid: number | null = null;
let running = false;
if (lockfile_present) {
try {
const raw = readFileSync(lockPath, 'utf-8').trim();
const parsed = parseInt(raw, 10);
if (Number.isFinite(parsed) && parsed > 0) {
pid = parsed;
try {
// kill -0 probes liveness without sending a real signal. Throws ESRCH
// if the PID is gone, EPERM if alive but owned by another user (which
// still tells us "something with that PID exists").
process.kill(parsed, 0);
running = true;
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
running = code === 'EPERM';
}
}
} catch {
/* unreadable lockfile, leave pid=null/running=false */
}
}
return {
installed: lockfile_present, // installed-or-running proxy; daemons writing the lock are installed
lockfile_present,
pid,
running,
};
}
// ---------------------------------------------------------------------------
// Orchestrator
// ---------------------------------------------------------------------------
interface BuildOpts {
sections?: Set<Section>;
}
async function buildLocalReport(
engine: BrainEngine,
opts: BuildOpts,
): Promise<StatusReport> {
const want = (s: Section) => !opts.sections || opts.sections.has(s);
const warnings: string[] = [];
const report: StatusReport = {
schema_version: SCHEMA_VERSION,
generated_at: new Date().toISOString(),
mode: 'local',
};
if (want('sync')) {
try {
const sources = await engine.executeRaw<{
id: string;
name: string;
local_path: string | null;
config: Record<string, unknown> | null;
}>(`SELECT id, name, local_path, config FROM sources ORDER BY id`);
report.sync = await buildSyncStatusReport(
engine,
sources.map((s) => ({ id: s.id, name: s.name, local_path: s.local_path, config: s.config ?? {} })),
);
} catch (err) {
warnings.push(`sync section failed: ${(err as Error).message}`);
}
}
if (want('cycle')) {
try {
report.cycle = await buildCycleSnapshot(engine);
} catch (err) {
warnings.push(`cycle section failed: ${(err as Error).message}`);
}
}
if (want('locks')) {
report.locks = await buildLocks(engine);
}
if (want('workers')) {
report.workers = buildWorkerSummary();
}
if (want('queue')) {
report.queue = await buildQueueCounts(engine);
}
if (want('autopilot')) {
report.autopilot = buildAutopilotStatus();
}
if (warnings.length > 0) report.warnings = warnings;
return report;
}
async function buildThinClientReport(
cfg: ReturnType<typeof loadConfig>,
opts: BuildOpts,
): Promise<StatusReport> {
const want = (s: Section) => !opts.sections || opts.sections.has(s);
const warnings: string[] = [];
const report: StatusReport = {
schema_version: SCHEMA_VERSION,
generated_at: new Date().toISOString(),
mode: 'thin-client',
};
if (want('sync') || want('cycle')) {
try {
const raw = await callRemoteTool(cfg!, 'get_status_snapshot', {});
const payload = unpackToolResult<{
schema_version: number;
sync: SyncStatusReport;
cycle: CycleSnapshot;
}>(raw);
if (want('sync')) report.sync = payload.sync;
if (want('cycle')) report.cycle = payload.cycle;
} catch (err) {
warnings.push(`remote snapshot failed: ${(err as Error).message}`);
}
}
if (want('locks')) report.locks = { local_only_remote: true };
if (want('workers')) report.workers = { local_only_remote: true };
if (want('queue')) report.queue = { local_only_remote: true };
if (want('autopilot')) report.autopilot = { local_only_remote: true };
if (warnings.length > 0) report.warnings = warnings;
return report;
}
// ---------------------------------------------------------------------------
// Human render
// ---------------------------------------------------------------------------
function renderHuman(report: StatusReport): string {
const lines: string[] = [];
lines.push('');
lines.push('GBrain Status');
lines.push('=============');
lines.push(`Mode: ${report.mode} · ${report.generated_at}`);
lines.push('');
// Sync
if (report.sync) {
lines.push('Sync:');
if (report.sync.sources.length === 0) {
lines.push(' (no sources registered)');
} else {
for (const s of report.sync.sources) {
const last = s.last_sync_at ?? 'never';
const stale = s.staleness_class === 'fresh' ? 'OK' : s.staleness_class.toUpperCase();
lines.push(
` [${stale}] ${s.source_id.padEnd(20)} ${last} pages=${s.pages} ` +
`embed=${s.embedding_coverage_pct.toFixed(0)}%`,
);
}
if (report.sync.unacknowledged_failures > 0) {
lines.push(` ${report.sync.unacknowledged_failures} unacknowledged sync failure(s)`);
}
}
lines.push('');
}
// Cycle
if (report.cycle) {
lines.push('Cycle:');
const fmt = (row: CycleRow | null, label: string) => {
if (!row) return ` ${label}: never run`;
const dur = row.duration_ms != null ? ` (${(row.duration_ms / 1000).toFixed(1)}s)` : '';
const totalsStr = row.totals && Object.keys(row.totals).length > 0
? ` totals=${JSON.stringify(row.totals)}`
: '';
return ` ${label}: ${row.finished_at}${dur}${totalsStr}`;
};
lines.push(fmt(report.cycle.last_full, 'Last full cycle'));
lines.push(fmt(report.cycle.last_targeted, 'Last targeted run'));
lines.push('');
}
// Locks
if (report.locks) {
lines.push('Locks:');
if ('local_only_remote' in report.locks) {
lines.push(' local-only — N/A on remote brain');
} else if (report.locks.length === 0) {
lines.push(' (none active)');
} else {
for (const l of report.locks) {
lines.push(
` ${l.id.padEnd(28)} pid=${l.holder_pid ?? '?'} expires=${l.ttl_expires_at ?? '?'}`,
);
}
}
lines.push('');
}
// Workers
if (report.workers) {
lines.push('Workers (last 24h):');
if ('local_only_remote' in report.workers) {
lines.push(' local-only — N/A on remote brain');
} else {
const w = report.workers;
lines.push(` crashes=${w.crashes_24h} clean_exits=${w.clean_exits_24h}`);
const causes = Object.entries(w.by_cause).filter(([, n]) => n > 0);
if (causes.length > 0) {
lines.push(` by_cause: ${causes.map(([k, v]) => `${k}=${v}`).join(', ')}`);
}
if (w.last_event_ts) lines.push(` last event: ${w.last_event_ts}`);
}
lines.push('');
}
// Queue
if (report.queue) {
lines.push('Queue (live):');
if ('local_only_remote' in report.queue) {
lines.push(' local-only — N/A on remote brain');
} else {
const q = report.queue;
lines.push(
` active=${q.active} waiting=${q.waiting} failed=${q.failed} dead=${q.dead} completed=${q.completed}`,
);
}
lines.push('');
}
// Autopilot
if (report.autopilot) {
lines.push('Autopilot:');
if ('local_only_remote' in report.autopilot) {
lines.push(' local-only — N/A on remote brain');
} else {
const a = report.autopilot;
if (a.running) {
lines.push(` running (PID ${a.pid})`);
} else if (a.lockfile_present) {
lines.push(` stale lockfile (PID ${a.pid ?? '?'} not alive). Run \`gbrain autopilot --install\` to restart.`);
} else {
lines.push(' not running. Install with `gbrain autopilot --install`.');
}
}
lines.push('');
}
// Warnings
if (report.warnings && report.warnings.length > 0) {
lines.push('Warnings:');
for (const w of report.warnings) lines.push(` ! ${w}`);
lines.push('');
}
return lines.join('\n');
}
// ---------------------------------------------------------------------------
// CLI entry
// ---------------------------------------------------------------------------
/**
* Parse `--section <name>` (and `--section=<name>` form) from args.
* Returns:
* - undefined → no filter (all sections)
* - Set<Section> → only these sections
* - 'usage_error' → bad section name (caller exits 2)
*/
export function parseSectionFlag(args: string[]): Set<Section> | undefined | 'usage_error' {
let raw: string | undefined;
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (a === '--section' && i + 1 < args.length) {
raw = args[i + 1];
break;
}
if (a.startsWith('--section=')) {
raw = a.slice('--section='.length);
break;
}
}
if (raw == null) return undefined;
if (!VALID_SECTIONS.includes(raw as Section)) return 'usage_error';
return new Set<Section>([raw as Section]);
}
export interface RunStatusResult {
exitCode: 0 | 1 | 2;
report?: StatusReport;
}
/**
* Programmatic entry. `cli.ts` calls this; tests can drive it directly.
*
* Engine is nullable so the thin-client path doesn't require a connected
* engine (matches the v0.31.1 `runThinClientRouted` posture in cli.ts).
*/
export async function runStatus(
engine: BrainEngine | null,
args: string[],
opts: { stdout?: (s: string) => void; stderr?: (s: string) => void } = {},
): Promise<RunStatusResult> {
const stdout = opts.stdout ?? ((s: string) => process.stdout.write(s));
const stderr = opts.stderr ?? ((s: string) => process.stderr.write(s));
const sectionFlag = parseSectionFlag(args);
if (sectionFlag === 'usage_error') {
stderr(
`gbrain status: invalid --section. Valid: ${VALID_SECTIONS.join('|')}\n`,
);
return { exitCode: 2 };
}
const sections = sectionFlag;
const json = args.includes('--json');
const cfg = loadConfig();
const useThinClient = cfg ? isThinClient(cfg) : false;
let report: StatusReport;
try {
if (useThinClient) {
report = await buildThinClientReport(cfg, { sections });
} else {
if (!engine) {
stderr('gbrain status: no engine connected (DB unreachable?). Run `gbrain doctor` to diagnose.\n');
return { exitCode: 1 };
}
report = await buildLocalReport(engine, { sections });
}
} catch (err) {
stderr(`gbrain status: snapshot failed: ${(err as Error).message}\n`);
return { exitCode: 1 };
}
if (json) {
stdout(JSON.stringify(report) + '\n');
} else {
stdout(renderHuman(report));
}
return { exitCode: 0, report };
}
+203
View File
@@ -0,0 +1,203 @@
/**
* Doctor check categorization — single source of truth.
*
* Every `Check.name` produced by `src/commands/doctor.ts` is assigned to
* exactly one of four categories:
*
* - brain : data-integrity signals (embedding coverage, page health, sync
* freshness, facts/takes/calibration data quality, contradictions,
* content-sanity audit findings). The "is my brain's data healthy?"
* question lives here.
* - skill : RESOLVER.md / skill conformance / routing-eval / filing-audit /
* whoknows expert routing. The "is my agent's skill dispatcher
* configured?" question.
* - ops : infrastructure liveness — DB connection, pgvector, RLS,
* supervisor, queue depth, OAuth confidential clients, autopilot
* lock scope, reranker/provider reachability. The "is the
* machinery actually running?" question.
* - meta : schema version, migrations, upgrade trail, eval capture, slug
* fallback audit, schema-pack drift. The "is gbrain itself
* coherent?" question.
*
* Why this matters: the doctor's legacy `health_score` ( 100 20×fails
* 5×warns ) weights every check equally. A skill routing miss costs the same
* as a corrupt embedding column. With categorization, the doctor surfaces a
* brain_checks_score and category_scores so operators see signal-to-noise on
* the question they're actually asking.
*
* Naming discipline: this module owns the *category penalty* score
* (`brain_checks_score`), which is ORTHOGONAL to `BrainHealth.brain_score`
* (the weighted 35/25/15/15/10 composite surfaced by the `brain_score`
* doctor check). The two answer different questions:
*
* - brain_score : "how healthy is the brain's data composition?"
* - brain_checks_score : "how many brain-category doctor checks failed?"
*
* The doctor renders both side by side.
*
* Drift contract: every check name that ships in doctor.ts MUST appear in
* exactly one set below. The drift-guard test in
* `test/doctor-categories.test.ts` enforces this by reading doctor.ts source
* via a tagged-string scan and asserting set membership exactly.
*
* If you add a new doctor check, you MUST add its name to the appropriate
* set here. The categorize step in `src/commands/doctor.ts` falls through
* to 'meta' for any unknown name AND emits a once-per-process stderr warn
* so a missing addition surfaces in dev runs even before the test catches
* it in CI.
*/
export type CheckCategory = 'brain' | 'skill' | 'ops' | 'meta';
/**
* Data-integrity signals. Everything that asks "is the brain's actual data
* healthy and complete?"
*/
export const BRAIN_CHECK_NAMES: ReadonlySet<string> = new Set([
'abandoned_threads',
'brain_score',
'calibration_freshness',
'child_table_orphans',
'content_sanity_audit_recent',
'contextual_retrieval_coverage',
'contradictions',
'conversation_facts_backlog',
'conversation_format_coverage',
'conversation_parser_probe_health',
'cross_modal_modality_backfill',
'cycle_freshness',
'effective_date_health',
'embedding_column_registry',
'embedding_env_override',
'embedding_provider',
'embedding_width_consistency',
'embeddings',
'eval_drift',
'facts_embedding_width_consistency',
'facts_extraction_health',
'facts_health',
'frontmatter_integrity',
'grade_confidence_drift',
'graph_coverage',
'graph_signals_coverage',
'image_assets',
'integrity',
'jsonb_integrity',
'markdown_body_completeness',
'nightly_quality_probe_health',
'ocr_health',
'orphan_ratio',
'oversized_pages',
'salience_health',
'scraper_junk_pages',
'source_routing_health',
'stub_guard_24h',
'sync_failures',
'sync_freshness',
'takes_weight_grid',
'unified_multimodal_coverage',
'voice_gate_health',
]);
/**
* Skill dispatcher signals. RESOLVER.md reachability, skill frontmatter
* conformance, brain-first compliance, expert-routing, filing audit.
*
* Deliberately small: only checks that scan the host's `skills/` tree or
* skill-routing fixtures. Brain-data quality checks (even ones with a
* skill-flavored name) live under 'brain'.
*/
export const SKILL_CHECK_NAMES: ReadonlySet<string> = new Set([
'resolver_health',
'skill_brain_first',
'skill_conformance',
'whoknows_health',
]);
/**
* Infrastructure liveness signals. DB, workers, OAuth, RLS, locks, providers.
*/
export const OPS_CHECK_NAMES: ReadonlySet<string> = new Set([
'alternative_providers',
'autopilot_lock_scope',
'batch_retry_health',
'brainstorm_health',
'connection',
'federation_health',
'home_dir_in_worktree',
'index_audit',
'oauth_confidential_client_health',
'orphan_clones',
'pgbouncer_prepare',
'pgvector',
'progressive_batch_audit_health',
'queue_health',
'reranker_health',
'rls',
'rls_event_trigger',
'search_mode',
'stale_locks',
'subagent_capability',
'subagent_health',
'supervisor',
'ze_embedding_health',
]);
/**
* gbrain-itself coherence signals. Schema migrations, version drift, audit
* housekeeping. Default category for unknown names (with stderr warn).
*/
export const META_CHECK_NAMES: ReadonlySet<string> = new Set([
'cycle_phase_scope',
'eval_capture',
'minions_migration',
'multi_source_drift',
'schema_pack_active',
'schema_pack_consistency',
'schema_pack_source_drift',
'schema_version',
'slug_fallback_audit',
'upgrade_errors',
]);
/**
* Stderr warn-once gate for unknown check names. Exported as a test seam so
* the categorizer test can re-trigger warns.
*/
const _warnedUnknown = new Set<string>();
export function _resetUnknownCheckWarningsForTest(): void {
_warnedUnknown.clear();
}
/**
* Map a check name to its category. Unknown names fall through to 'meta'
* with a once-per-process stderr warning — the test in
* `test/doctor-categories.test.ts` is the structural guard, and the warn is
* the runtime backstop so contributors notice in dev before CI fails.
*/
export function categorizeCheck(name: string): CheckCategory {
if (BRAIN_CHECK_NAMES.has(name)) return 'brain';
if (SKILL_CHECK_NAMES.has(name)) return 'skill';
if (OPS_CHECK_NAMES.has(name)) return 'ops';
if (META_CHECK_NAMES.has(name)) return 'meta';
if (!_warnedUnknown.has(name)) {
_warnedUnknown.add(name);
process.stderr.write(
`[doctor-categories] unknown check name '${name}' — defaulting to 'meta'. Add it to src/core/doctor-categories.ts.\n`,
);
}
return 'meta';
}
/**
* Skill-category check group. Used by buildChecks's scope-branch gates to
* SKIP the (expensive, filesystem-walking) skill check group when the caller
* asked for scope=brain. This is the load-bearing escape hatch that makes
* `gbrain doctor --scope=brain` sub-second on a brain with thousands of
* skills (per D9 in the plan).
*
* Use this set as the source of truth for "do these checks belong to the
* skill group that the scope-branch gate skips?" — keeping the gate
* categorization and the per-check categorization aligned.
*/
export const SKILL_CHECK_GROUP_NAMES: ReadonlySet<string> = SKILL_CHECK_NAMES;
+70
View File
@@ -1929,6 +1929,74 @@ const get_brain_identity: Operation = {
// intentionally no cliHints — banner-only op
};
/**
* v0.41.19.0 — `gbrain status` thin-client surface.
*
* Returns a snapshot of sync freshness + last cycle state for thin-client
* `gbrain status` callers. Per D2/D10 in the plan:
*
* - Scope: admin (NOT localOnly). The op exposes operational state
* including sync timestamps and cycle metadata. Locking it to admin
* matches the `run_doctor` posture and prevents future feature creep
* (adding locks/workers/queue counters) from quietly leaking ops state
* to read-scoped clients.
*
* - Payload: `{schema_version: 1, sync, cycle}` ONLY. Locks, Workers,
* Queue, and Autopilot sections are deliberately omitted from the
* remote payload — they are local-host concerns that thin-client
* callers shouldn't see at all (and the local `gbrain status` renders
* them as "N/A on remote brain" instead of pretending they exist).
*
* - The local CLI composes the same data plus the local-only sections
* directly (no MCP round-trip when running against ~/.gbrain).
*/
const get_status_snapshot: Operation = {
name: 'get_status_snapshot',
description: 'Snapshot for `gbrain status` thin-client mode: sync freshness + last cycle. Admin-scope.',
params: {},
handler: async (ctx) => {
const { buildSyncStatusReport } = await import('../commands/sync.ts');
const { buildCycleSnapshot } = await import('../commands/status.ts');
// Pull sources first (handles brains with zero declared sources too).
let sources: Array<{ id: string; name: string; local_path: string | null; config: Record<string, unknown> }> = [];
try {
const rows = await ctx.engine.executeRaw<{
id: string;
name: string;
local_path: string | null;
config: Record<string, unknown> | null;
}>(
`SELECT id, name, local_path, config FROM sources WHERE COALESCE(archived, FALSE) = FALSE ORDER BY id`,
);
sources = rows.map((r) => ({
id: r.id,
name: r.name,
local_path: r.local_path,
config: r.config ?? {},
}));
} catch {
// Pre-v0.26.5 brains may lack the `archived` column; degrade to all rows.
const rows = await ctx.engine.executeRaw<{
id: string;
name: string;
local_path: string | null;
config: Record<string, unknown> | null;
}>(`SELECT id, name, local_path, config FROM sources ORDER BY id`);
sources = rows.map((r) => ({
id: r.id,
name: r.name,
local_path: r.local_path,
config: r.config ?? {},
}));
}
const sync = await buildSyncStatusReport(ctx.engine, sources);
const cycle = await buildCycleSnapshot(ctx.engine);
return { schema_version: 1 as const, sync, cycle };
},
scope: 'admin',
localOnly: false,
};
/**
* Multi-topology v1 (Tier B): structured doctor report for remote callers.
*
@@ -4296,6 +4364,8 @@ export const operations: Operation[] = [
get_stats, get_health, run_doctor, get_versions, revert_version,
// v0.31.1 (Issue #734): thin-client banner identity packet (read-scope, banner-only)
get_brain_identity,
// v0.41.19.0: thin-client `gbrain status` payload (admin-scope, sync + cycle only)
get_status_snapshot,
// Sync
sync_brain,
// Raw data
+181
View File
@@ -0,0 +1,181 @@
/**
* v0.41.19.0 brain_checks_score + category_scores invariants.
*
* Pinned contracts:
* - health_score math is byte-identical to pre-v0.41.19.0 for a fixed
* check set (back-compat invariant for MCP consumers, --remediate,
* remote doctor).
* - brain_checks_score is the same penalty math restricted to brain
* checks.
* - category_scores sum across the 4 categories is NOT the same as
* health_score (different math — each is 100-based). This is by
* design.
* - Renaming regression: no field called `brain_health_score` ships
* in the JSON envelope. The shipped name is `brain_checks_score`
* (codex MAJOR-8 — avoid collision with the existing weighted
* BrainHealth.brain_score).
* - Tags fall through to `categorizeCheck(name)` when not pre-tagged.
* - schema_version stays at 2 (additive fields are non-breaking).
*/
import { describe, test, expect } from 'bun:test';
import { computeDoctorReport, type Check } from '../src/commands/doctor.ts';
function check(name: string, status: Check['status'], message = ''): Check {
return { name, status, message };
}
describe('computeDoctorReport — back-compat health_score invariant', () => {
test('health_score = 100 on all-OK fixed check set', () => {
const checks: Check[] = [
check('brain_score', 'ok'),
check('connection', 'ok'),
check('resolver_health', 'ok'),
check('schema_version', 'ok'),
];
const r = computeDoctorReport(checks);
expect(r.health_score).toBe(100);
expect(r.status).toBe('healthy');
});
test('health_score = 100 20×fails 5×warns, floor 0', () => {
const checks: Check[] = [
check('connection', 'fail'), // -20
check('brain_score', 'warn'), // -5
check('resolver_health', 'warn'), // -5
check('schema_version', 'ok'),
];
const r = computeDoctorReport(checks);
expect(r.health_score).toBe(70);
});
test('health_score floors at 0 even with many fails', () => {
const checks: Check[] = Array.from({ length: 10 }, (_, i) =>
check(`fail_${i}`, 'fail'),
);
const r = computeDoctorReport(checks);
expect(r.health_score).toBe(0);
});
test('schema_version stays at 2', () => {
const r = computeDoctorReport([check('connection', 'ok')]);
expect(r.schema_version).toBe(2);
});
test('504 skill warns DO drag down health_score (this is the legacy behavior we are preserving)', () => {
const checks: Check[] = [
check('connection', 'ok'),
check('brain_score', 'ok'),
check('resolver_health', 'warn', '504 issues'),
];
const r = computeDoctorReport(checks);
// Just one check, warn = -5.
expect(r.health_score).toBe(95);
});
});
describe('computeDoctorReport — brain_checks_score', () => {
test('brain_checks_score is 100 when all brain checks are OK, even if skill checks are failing', () => {
const checks: Check[] = [
check('brain_score', 'ok'),
check('embedding_provider', 'ok'),
check('sync_freshness', 'ok'),
check('resolver_health', 'fail', '504 warnings'), // skill, not counted
check('skill_conformance', 'warn'), // skill, not counted
];
const r = computeDoctorReport(checks);
expect(r.brain_checks_score).toBe(100);
});
test('brain_checks_score reflects ONLY brain-category failures', () => {
const checks: Check[] = [
check('embedding_provider', 'fail'), // brain -20
check('graph_coverage', 'warn'), // brain -5
check('connection', 'fail'), // ops, not counted
check('schema_version', 'warn'), // meta, not counted
];
const r = computeDoctorReport(checks);
expect(r.brain_checks_score).toBe(75);
});
test('the OpenClaw 504-warning scenario: brain ~100, skill ~0, overall low', () => {
const checks: Check[] = [
check('brain_score', 'ok'),
check('embedding_provider', 'ok'),
check('sync_freshness', 'ok'),
];
// 30 skill warnings (the canonical pollution scenario, scaled down for test brevity)
for (let i = 0; i < 30; i++) checks.push(check(`resolver_health`, 'warn'));
// Categorization re-tags each `resolver_health` push as skill, so all 30 warns sit in skill.
const r = computeDoctorReport(checks);
expect(r.brain_checks_score).toBe(100);
expect(r.category_scores.skill).toBe(0); // 30 * -5 = -150, floored at 0
expect(r.health_score).toBe(0); // overall is dragged to 0 (the problem!)
});
});
describe('computeDoctorReport — category_scores', () => {
test('every category gets its own 100-floor penalty score', () => {
const checks: Check[] = [
check('brain_score', 'warn'),
check('resolver_health', 'fail'),
check('connection', 'warn'),
check('schema_version', 'fail'),
];
const r = computeDoctorReport(checks);
expect(r.category_scores.brain).toBe(95); // 1 warn
expect(r.category_scores.skill).toBe(80); // 1 fail
expect(r.category_scores.ops).toBe(95); // 1 warn
expect(r.category_scores.meta).toBe(80); // 1 fail
});
test('empty category gets 100 (vacuous truth)', () => {
const checks: Check[] = [check('brain_score', 'ok')];
const r = computeDoctorReport(checks);
expect(r.category_scores.brain).toBe(100);
expect(r.category_scores.skill).toBe(100); // no skill checks ran
expect(r.category_scores.ops).toBe(100);
expect(r.category_scores.meta).toBe(100);
});
});
describe('computeDoctorReport — categorization fall-through', () => {
test('checks without category get tagged via categorizeCheck(name)', () => {
const r = computeDoctorReport([
check('embedding_provider', 'ok'),
check('resolver_health', 'ok'),
check('connection', 'ok'),
check('schema_version', 'ok'),
]);
expect(r.checks.find((c) => c.name === 'embedding_provider')?.category).toBe('brain');
expect(r.checks.find((c) => c.name === 'resolver_health')?.category).toBe('skill');
expect(r.checks.find((c) => c.name === 'connection')?.category).toBe('ops');
expect(r.checks.find((c) => c.name === 'schema_version')?.category).toBe('meta');
});
test('pre-tagged category survives compute (no override)', () => {
const r = computeDoctorReport([
{ name: 'made_up_unknown_name', status: 'ok', message: '', category: 'brain' },
]);
expect(r.checks[0].category).toBe('brain');
expect(r.brain_checks_score).toBe(100);
});
});
describe('computeDoctorReport — renaming regression (codex MAJOR-8)', () => {
test('NO field named brain_health_score ships in the JSON envelope', () => {
const r = computeDoctorReport([check('brain_score', 'ok')]);
const jsonShape = JSON.parse(JSON.stringify(r));
expect(jsonShape).not.toHaveProperty('brain_health_score');
expect(jsonShape).toHaveProperty('brain_checks_score');
});
test('the existing weighted BrainHealth.brain_score is NOT replaced — it stays in the checks list', () => {
// The brain_score check surfaces engine.getHealth().brain_score (weighted
// 35/25/15/15/10). It's orthogonal to brain_checks_score. Make sure the
// check name is still resolvable as a `brain` category entry.
const r = computeDoctorReport([check('brain_score', 'ok', 'Brain score 92/100')]);
expect(r.checks[0].category).toBe('brain');
expect(r.checks[0].message).toContain('92');
});
});
+161
View File
@@ -0,0 +1,161 @@
/**
* Drift guard for src/core/doctor-categories.ts.
*
* Reads src/commands/doctor.ts source via a literal-string scan, enumerates
* every `name: '<...>'` Check name, and asserts each appears in exactly ONE
* category set. The union of the four sets must equal the discovered names
* exactly — no orphans, no extras.
*
* This is the structural failure the v0.41.19.0 plan-eng-review caught:
* doctor.ts grows new checks regularly; without this guard, the
* categorization map silently goes stale and unknown checks degrade to
* 'meta' without anyone noticing.
*/
import { describe, test, expect, beforeEach } from 'bun:test';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import {
BRAIN_CHECK_NAMES,
SKILL_CHECK_NAMES,
OPS_CHECK_NAMES,
META_CHECK_NAMES,
categorizeCheck,
_resetUnknownCheckWarningsForTest,
} from '../src/core/doctor-categories.ts';
const DOCTOR_TS_PATH = join(import.meta.dir, '..', 'src', 'commands', 'doctor.ts');
function enumerateCheckNames(): Set<string> {
const source = readFileSync(DOCTOR_TS_PATH, 'utf-8');
const names = new Set<string>();
// 1) Inline object-literal form: `{ name: 'foo', ... }`.
for (const m of source.matchAll(/name:\s*['"]([a-z][a-z0-9_]+)['"]/g)) {
names.add(m[1]);
}
// 2) Helper-function form: `const name = 'foo';` inside a check helper.
// Catches checks like `nightly_quality_probe_health` and
// `conversation_facts_backlog` that build the Check from a captured
// name constant.
for (const m of source.matchAll(/const\s+name\s*=\s*['"]([a-z][a-z0-9_]+)['"]/g)) {
names.add(m[1]);
}
return names;
}
describe('doctor-categories drift guard', () => {
test('every check name in doctor.ts source belongs to exactly one category set', () => {
const discovered = enumerateCheckNames();
const allCategorized = new Set<string>([
...BRAIN_CHECK_NAMES,
...SKILL_CHECK_NAMES,
...OPS_CHECK_NAMES,
...META_CHECK_NAMES,
]);
const missing: string[] = [];
for (const name of discovered) {
if (!allCategorized.has(name)) missing.push(name);
}
if (missing.length > 0) {
throw new Error(
`These check names appear in doctor.ts but are not categorized in ` +
`src/core/doctor-categories.ts: ${missing.sort().join(', ')}. ` +
`Add each to BRAIN/SKILL/OPS/META_CHECK_NAMES.`,
);
}
});
test('no check name appears in more than one category set', () => {
const counts = new Map<string, string[]>();
const tag = (s: ReadonlySet<string>, label: string) => {
for (const n of s) {
if (!counts.has(n)) counts.set(n, []);
counts.get(n)!.push(label);
}
};
tag(BRAIN_CHECK_NAMES, 'brain');
tag(SKILL_CHECK_NAMES, 'skill');
tag(OPS_CHECK_NAMES, 'ops');
tag(META_CHECK_NAMES, 'meta');
const dupes: string[] = [];
for (const [name, cats] of counts) {
if (cats.length > 1) dupes.push(`${name} in [${cats.join(', ')}]`);
}
expect(dupes).toEqual([]);
});
test('every categorized name is currently used in doctor.ts source (no stale entries)', () => {
const discovered = enumerateCheckNames();
const allCategorized = new Set<string>([
...BRAIN_CHECK_NAMES,
...SKILL_CHECK_NAMES,
...OPS_CHECK_NAMES,
...META_CHECK_NAMES,
]);
const stale: string[] = [];
for (const name of allCategorized) {
if (!discovered.has(name)) stale.push(name);
}
// Stale entries are warnings, not hard errors — a check may be temporarily
// removed during refactor. But the build should still flag them so we
// catch the drift quickly. Use a soft assertion via console hint and a
// strict expectation that the count is small (<=2). Adjust if real
// refactors require more headroom.
if (stale.length > 2) {
throw new Error(
`These categorized names no longer appear in doctor.ts: ${stale.sort().join(', ')}. ` +
`Remove them from src/core/doctor-categories.ts.`,
);
}
});
});
describe('categorizeCheck', () => {
beforeEach(() => {
_resetUnknownCheckWarningsForTest();
});
test('returns the right category for a known brain name', () => {
expect(categorizeCheck('embedding_provider')).toBe('brain');
expect(categorizeCheck('graph_coverage')).toBe('brain');
expect(categorizeCheck('sync_freshness')).toBe('brain');
});
test('returns the right category for a known skill name', () => {
expect(categorizeCheck('resolver_health')).toBe('skill');
expect(categorizeCheck('skill_conformance')).toBe('skill');
});
test('returns the right category for a known ops name', () => {
expect(categorizeCheck('connection')).toBe('ops');
expect(categorizeCheck('rls')).toBe('ops');
expect(categorizeCheck('supervisor')).toBe('ops');
});
test('returns the right category for a known meta name', () => {
expect(categorizeCheck('schema_version')).toBe('meta');
expect(categorizeCheck('upgrade_errors')).toBe('meta');
});
test('unknown check name falls through to meta with a stderr warn (once per process)', () => {
const originalWrite = process.stderr.write.bind(process.stderr);
const captured: string[] = [];
(process.stderr as { write: typeof process.stderr.write }).write = ((
chunk: string | Uint8Array,
) => {
captured.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString());
return true;
}) as typeof process.stderr.write;
try {
expect(categorizeCheck('made_up_check_name_not_in_any_set')).toBe('meta');
expect(categorizeCheck('made_up_check_name_not_in_any_set')).toBe('meta');
const warns = captured.filter((c) => c.includes('made_up_check_name_not_in_any_set'));
expect(warns.length).toBe(1);
} finally {
(process.stderr as { write: typeof process.stderr.write }).write = originalWrite;
}
});
});
+14 -4
View File
@@ -64,10 +64,20 @@ async function getCheck(name: string, env: Record<string, string | undefined>) {
process.exit = origExit;
}
const text = captured.join('');
// The doctor's JSON envelope is the last `{` block containing `"checks":`.
const idx = text.lastIndexOf('"checks"');
const objStart = idx >= 0 ? text.lastIndexOf('{', idx) : text.lastIndexOf('{');
const jsonStr = objStart >= 0 ? text.slice(objStart) : text;
// The doctor's JSON envelope is the LAST line that starts with
// `{"schema_version"`. v0.41.19.0 added nested objects to the envelope
// (category_scores), so a "find the `{` before `\"checks\"`" heuristic
// no longer works — it walks back to category_scores's `{` instead of
// the outer one. Anchor on the canonical envelope prefix instead.
const lines = text.split('\n');
let jsonStr = '';
for (let i = lines.length - 1; i >= 0; i--) {
const trimmed = lines[i].trim();
if (trimmed.startsWith('{"schema_version"')) {
jsonStr = trimmed;
break;
}
}
let parsed: { checks: { name: string; status: string; message: string }[] };
try {
parsed = JSON.parse(jsonStr);
+94
View File
@@ -0,0 +1,94 @@
/**
* v0.41.19.0 `--scope=brain` skip-computation contract.
*
* The plan-eng-review D4/D9 lock: `--scope=brain` must SKIP computation of
* the SKILL check group (resolver_health, skill_conformance,
* skill_brain_first, whoknows_health), not just filter the output. The
* observable contract: with `--scope=brain`, the returned checks list
* contains zero entries with `category: 'skill'`, AND the resolver walk is
* NOT performed.
*
* This is the "sub-second on a brain with thousands of skills" win. Hermetic
* test: passes `engine=null` so no DB or PGLite is needed; uses `--fast` to
* skip the DB check path; sets $GBRAIN_SKILLS_DIR to a tmpdir to control the
* resolver walk's input cheaply.
*/
import { describe, test, expect } from 'bun:test';
import { mkdtempSync, writeFileSync, mkdirSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { withEnv } from './helpers/with-env.ts';
import { buildChecks } from '../src/commands/doctor.ts';
function makeSkillsTree(): string {
const root = mkdtempSync(join(tmpdir(), 'gbrain-scope-test-'));
mkdirSync(join(root, 'foo'), { recursive: true });
// A minimal SKILL.md so checkResolvable / skill_conformance / skill_brain_first
// all have content to scan when scope=all. The body doesn't need to be
// valid in detail; we only care about whether the function INVOKES the
// resolver walk.
writeFileSync(
join(root, 'foo', 'SKILL.md'),
'---\nname: foo\ntriggers:\n - "test trigger"\nbrain_first: exempt\n---\n\n# foo\n\nA test skill.\n',
);
writeFileSync(
join(root, 'RESOLVER.md'),
'| Skill | Triggers |\n|---|---|\n| **foo**: test trigger |\n',
);
return root;
}
describe('buildChecks --scope=brain skip-computation contract', () => {
test('SKILL group is absent from the checks list under --scope=brain', async () => {
const skillsDir = makeSkillsTree();
await withEnv({ GBRAIN_SKILLS_DIR: skillsDir, GBRAIN_NO_BANNER: '1' }, async () => {
const checks = await buildChecks(null, ['--scope=brain', '--fast']);
const skillChecks = checks.filter(
(c) =>
c.name === 'resolver_health' ||
c.name === 'skill_conformance' ||
c.name === 'skill_brain_first' ||
c.name === 'whoknows_health',
);
expect(skillChecks).toEqual([]);
});
});
test('SKILL group IS present under default scope (--fast alone)', async () => {
const skillsDir = makeSkillsTree();
await withEnv({ GBRAIN_SKILLS_DIR: skillsDir, GBRAIN_NO_BANNER: '1' }, async () => {
const checks = await buildChecks(null, ['--fast']);
const names = new Set(checks.map((c) => c.name));
expect(names.has('resolver_health')).toBe(true);
// skill_conformance + skill_brain_first only run if skillsDir is detected
// AND has SKILL.md files — the makeSkillsTree fixture provides one.
expect(names.has('skill_conformance')).toBe(true);
expect(names.has('skill_brain_first')).toBe(true);
});
});
test('--scope=brain still emits non-skill checks (the brain figure is meaningful)', async () => {
const skillsDir = makeSkillsTree();
await withEnv({ GBRAIN_SKILLS_DIR: skillsDir, GBRAIN_NO_BANNER: '1' }, async () => {
const checks = await buildChecks(null, ['--scope=brain', '--fast']);
// At least one non-skill check must be present (e.g. the migration
// health/meta checks that always run in the FS phase, or schema_version
// which is the canonical META check).
expect(checks.length).toBeGreaterThan(0);
const cats = new Set(checks.map((c) => c.category));
// No skill-category checks at all.
expect(cats.has('skill')).toBe(false);
});
});
test('--scope=brain does NOT emit a "Could not find skills directory" warn (we deliberately skipped, not failed)', async () => {
await withEnv({ GBRAIN_SKILLS_DIR: '/nonexistent/path/should/not/exist', GBRAIN_NO_BANNER: '1' }, async () => {
const checks = await buildChecks(null, ['--scope=brain', '--fast']);
const resolverHealth = checks.find((c) => c.name === 'resolver_health');
// Under scope=brain, we don't even attempt to find the skills dir, so
// the "Could not find skills directory" branch should NOT fire.
expect(resolverHealth).toBeUndefined();
});
});
});
+203
View File
@@ -0,0 +1,203 @@
/**
* E2E `gbrain status` against a real PGLite brain.
*
* Seeds:
* - 1 source row
* - 1 `autopilot-cycle` completed row with `result.report.totals`
* - 1 `autopilot-embed` completed row (newer; covers the dual-row
* "Last full" + "Last targeted" output per D3)
* - 1 active gbrain_cycle_locks row
* - some minion_jobs counts (waiting/active/dead)
*
* Asserts:
* - dual cycle rows surface (full < targeted in timestamp)
* - cycle totals come from `result.report.totals`, NOT `result.totals`
* (codex MINOR-3)
* - JSON envelope shape (schema_version: 1)
* - active lock surfaces
* - live queue counts (NO time-window filter — codex MAJOR-6)
*/
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
import { PGLiteEngine } from '../../src/core/pglite-engine.ts';
import { resetPgliteState } from '../helpers/reset-pglite.ts';
import { runStatus } from '../../src/commands/status.ts';
let engine: PGLiteEngine;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
});
afterAll(async () => {
await engine.disconnect();
});
beforeEach(async () => {
await resetPgliteState(engine);
});
async function seedAutopilotCycle(engine: PGLiteEngine, opts: {
name: string;
finishedAt: string;
totals?: Record<string, unknown>;
}) {
const result = opts.totals
? { partial: false, status: 'ok', report: { totals: opts.totals } }
: { partial: false, status: 'ok', report: {} };
await engine.executeRaw(
`INSERT INTO minion_jobs (queue, name, data, status, started_at, finished_at, result)
VALUES ('default', $1, '{}'::jsonb, 'completed',
$2::timestamptz - INTERVAL '5 seconds', $2::timestamptz, $3::jsonb)`,
[opts.name, opts.finishedAt, JSON.stringify(result)],
);
}
async function seedSource(engine: PGLiteEngine, id: string) {
await engine.executeRaw(
`INSERT INTO sources (id, name) VALUES ($1, $2) ON CONFLICT (id) DO NOTHING`,
[id, id],
);
}
describe('gbrain status E2E (PGLite)', () => {
test('JSON envelope shape includes schema_version + sync + cycle + locks + workers + queue + autopilot', async () => {
await seedSource(engine, 'default');
let jsonOut = '';
const r = await runStatus(engine, ['--json'], {
stdout: (s) => {
jsonOut += s;
},
stderr: () => {},
});
expect(r.exitCode).toBe(0);
const parsed = JSON.parse(jsonOut.trim());
expect(parsed.schema_version).toBe(1);
expect(parsed.mode).toBe('local');
expect(parsed).toHaveProperty('sync');
expect(parsed).toHaveProperty('cycle');
expect(parsed).toHaveProperty('locks');
expect(parsed).toHaveProperty('workers');
expect(parsed).toHaveProperty('queue');
expect(parsed).toHaveProperty('autopilot');
});
test('dual cycle rows: last_full + last_targeted surface independently', async () => {
await seedSource(engine, 'default');
// Older full cycle row + newer targeted (embed) row.
await seedAutopilotCycle(engine, {
name: 'autopilot-cycle',
finishedAt: '2026-05-20T10:00:00Z',
totals: { synth_pages_written: 7, facts_consolidated: 12 },
});
await seedAutopilotCycle(engine, {
name: 'autopilot-embed',
finishedAt: '2026-05-26T22:30:00Z',
totals: { chunks_embedded: 100 },
});
let jsonOut = '';
await runStatus(engine, ['--json'], {
stdout: (s) => {
jsonOut += s;
},
stderr: () => {},
});
const parsed = JSON.parse(jsonOut.trim());
expect(parsed.cycle.last_full).toBeDefined();
expect(parsed.cycle.last_full.name).toBe('autopilot-cycle');
expect(parsed.cycle.last_full.totals).toEqual({
synth_pages_written: 7,
facts_consolidated: 12,
});
expect(parsed.cycle.last_targeted).toBeDefined();
// Last targeted should be the newer autopilot-embed row (since it
// matches `name LIKE 'autopilot-%'` and is newer than autopilot-cycle).
expect(parsed.cycle.last_targeted.name).toBe('autopilot-embed');
expect(parsed.cycle.last_targeted.totals).toEqual({ chunks_embedded: 100 });
});
test('cycle totals come from result.report.totals, NOT result.totals (codex MINOR-3)', async () => {
await seedSource(engine, 'default');
// Hand-craft a row where totals are mis-placed at the top level — the
// status renderer should IGNORE them (returns null), not silently surface
// the wrong shape.
await engine.executeRaw(
`INSERT INTO minion_jobs (queue, name, data, status, started_at, finished_at, result)
VALUES ('default', 'autopilot-cycle', '{}'::jsonb, 'completed',
NOW() - INTERVAL '5 seconds', NOW(),
'{"totals":{"wrong_place":42}}'::jsonb)`,
);
let jsonOut = '';
await runStatus(engine, ['--json'], {
stdout: (s) => {
jsonOut += s;
},
stderr: () => {},
});
const parsed = JSON.parse(jsonOut.trim());
expect(parsed.cycle.last_full).toBeDefined();
// totals at the WRONG path should NOT be surfaced.
expect(parsed.cycle.last_full.totals).toBeNull();
});
test('active lock surfaces in the locks section', async () => {
await seedSource(engine, 'default');
await engine.executeRaw(
`INSERT INTO gbrain_cycle_locks (id, holder_pid, holder_host, acquired_at, ttl_expires_at, last_refreshed_at)
VALUES ('gbrain-cycle', 1234, 'test-host', NOW(), NOW() + INTERVAL '30 minutes', NOW())`,
);
let jsonOut = '';
await runStatus(engine, ['--json'], {
stdout: (s) => {
jsonOut += s;
},
stderr: () => {},
});
const parsed = JSON.parse(jsonOut.trim());
expect(Array.isArray(parsed.locks)).toBe(true);
expect(parsed.locks).toHaveLength(1);
expect(parsed.locks[0].id).toBe('gbrain-cycle');
expect(parsed.locks[0].holder_pid).toBe(1234);
});
test('live queue counts include OLD waiting/active jobs (no time window — codex MAJOR-6)', async () => {
await seedSource(engine, 'default');
// Seed an OLD waiting job (created 10 days ago). The status query MUST
// surface it — that's exactly the kind of stuck job operators want to see.
await engine.executeRaw(
`INSERT INTO minion_jobs (queue, name, data, status, created_at)
VALUES ('default', 'stale-waiting', '{}'::jsonb, 'waiting',
NOW() - INTERVAL '10 days')`,
);
let jsonOut = '';
await runStatus(engine, ['--json'], {
stdout: (s) => {
jsonOut += s;
},
stderr: () => {},
});
const parsed = JSON.parse(jsonOut.trim());
expect(parsed.queue.waiting).toBe(1);
});
test('--section sync emits only the sync section (filter works)', async () => {
await seedSource(engine, 'default');
let jsonOut = '';
await runStatus(engine, ['--json', '--section', 'sync'], {
stdout: (s) => {
jsonOut += s;
},
stderr: () => {},
});
const parsed = JSON.parse(jsonOut.trim());
expect(parsed.sync).toBeDefined();
expect(parsed.cycle).toBeUndefined();
expect(parsed.locks).toBeUndefined();
expect(parsed.workers).toBeUndefined();
expect(parsed.queue).toBeUndefined();
expect(parsed.autopilot).toBeUndefined();
});
});
+72
View File
@@ -0,0 +1,72 @@
/**
* `get_status_snapshot` MCP op contract.
*
* Pins the v0.41.19.0 wave's per-op decisions:
* - scope: 'admin' (codex MAJOR-9 / D10 — prevents read-scoped clients
* from seeing brain-host operational state).
* - localOnly: false (remote thin-client `gbrain status` callers need
* it via HTTP MCP — that's the whole point).
* - payload returns ONLY {schema_version: 1, sync, cycle}. Locks /
* Workers / Queue / Autopilot are deliberately omitted from the
* remote shape; the local CLI's `gbrain status` renders them as
* "N/A on remote brain" instead.
*
* Hermetic — stubs the engine. The cycle / sync helpers degrade
* gracefully when their queries throw, so a stubbed `executeRaw` that
* returns `[]` is enough to exercise the shape.
*/
import { describe, test, expect } from 'bun:test';
import { operations, operationsByName } from '../src/core/operations.ts';
describe('get_status_snapshot op definition', () => {
test('exists and is registered in the operations array', () => {
expect(operationsByName.get_status_snapshot).toBeDefined();
expect(operations.find((o) => o.name === 'get_status_snapshot')).toBeDefined();
});
test('scope is admin', () => {
const op = operationsByName.get_status_snapshot;
expect(op.scope).toBe('admin');
});
test('localOnly is false (must be remote-callable for thin-client status)', () => {
const op = operationsByName.get_status_snapshot;
expect(op.localOnly).toBe(false);
});
test('takes no params', () => {
const op = operationsByName.get_status_snapshot;
expect(op.params).toEqual({});
});
});
describe('get_status_snapshot handler shape', () => {
test('returns only {schema_version, sync, cycle} keys (no Locks/Workers/Queue/Autopilot)', async () => {
const op = operationsByName.get_status_snapshot;
// Stub engine that returns empty rows for any executeRaw and a minimal
// BrainEngine shape. The sync helper degrades to an empty sources list
// and a synthetic SyncStatusReport; the cycle helper degrades to
// {last_full: null, last_targeted: null}.
const stubEngine: any = {
kind: 'pglite',
executeRaw: async () => [],
getConfig: async () => null,
};
const ctx: any = {
engine: stubEngine,
config: {},
logger: { info: () => {}, warn: () => {}, error: () => {} },
dryRun: false,
remote: true,
};
const result = (await op.handler(ctx, {})) as Record<string, unknown>;
expect(result.schema_version).toBe(1);
expect(result).toHaveProperty('sync');
expect(result).toHaveProperty('cycle');
expect(result).not.toHaveProperty('locks');
expect(result).not.toHaveProperty('workers');
expect(result).not.toHaveProperty('queue');
expect(result).not.toHaveProperty('autopilot');
});
});
+76
View File
@@ -0,0 +1,76 @@
/**
* Pure-function unit tests for `gbrain status` orchestrator helpers.
*
* Hermetic — no PGLite, no DB. Drives the exported helpers (parseSectionFlag,
* runStatus with engine=null in thin-client-disabled mode) and asserts:
* - JSON envelope shape stability (schema_version: 1)
* - --section filter validation (unknown → exit 2)
* - exit code policy (0 success/degraded, 1 snapshot failure, 2 usage)
* - thin-client local-only-N/A render for Locks/Workers/Queue/Autopilot
* (we exercise this via a stubbed cfg that mimics thin-client mode)
*
* The E2E test at test/e2e/status-pglite.test.ts covers the full PGLite +
* fake-minion_jobs + fake-supervisor-audit path.
*/
import { describe, test, expect } from 'bun:test';
import { parseSectionFlag, runStatus } from '../src/commands/status.ts';
describe('parseSectionFlag', () => {
test('no --section flag → undefined (all sections)', () => {
expect(parseSectionFlag([])).toBeUndefined();
expect(parseSectionFlag(['--json'])).toBeUndefined();
});
test('--section <name> form returns the set', () => {
const r = parseSectionFlag(['--section', 'sync']);
expect(r).toBeInstanceOf(Set);
expect((r as Set<string>).has('sync')).toBe(true);
});
test('--section=<name> form returns the set', () => {
const r = parseSectionFlag(['--section=cycle']);
expect(r).toBeInstanceOf(Set);
expect((r as Set<string>).has('cycle')).toBe(true);
});
test('unknown section returns usage_error', () => {
expect(parseSectionFlag(['--section', 'bogus'])).toBe('usage_error');
expect(parseSectionFlag(['--section=nonsense'])).toBe('usage_error');
});
test('every valid section is accepted', () => {
for (const s of ['sync', 'cycle', 'locks', 'workers', 'queue', 'autopilot']) {
const r = parseSectionFlag(['--section', s]);
expect(r).toBeInstanceOf(Set);
expect((r as Set<string>).has(s)).toBe(true);
}
});
});
describe('runStatus exit codes', () => {
test('--section invalid → exit 2 (usage error)', async () => {
let captured = '';
const r = await runStatus(null, ['--section', 'bogus'], {
stdout: () => {},
stderr: (s: string) => {
captured += s;
},
});
expect(r.exitCode).toBe(2);
expect(captured).toContain('invalid --section');
});
test('local mode with engine=null → exit 1 (snapshot failure)', async () => {
let captured = '';
const r = await runStatus(null, [], {
stdout: () => {},
stderr: (s: string) => {
captured += s;
},
});
// Without a config + engine, status can't build the local snapshot.
expect(r.exitCode).toBe(1);
expect(captured).toMatch(/snapshot failed|no engine connected/);
});
});