feat(advisor,skillpack): list_brain_skillpack MCP tool + gbrain advisor

Topology B: dedicated source-scoped list_brain_skillpack op + get_skill source_id
disambiguation (brain-resident-locate.ts); git scaffold-spec never a server FS
path; source-aware schema match. LEARN_INSTRUCTION + serve-http banner.

gbrain advisor: read-only ranked actions from brain state (8 resilient collectors,
shared renderer, JSONL history, --json severity exit codes, local-only argv
--apply dispatcher). Exposed over MCP behind mcp.publish_advisor (default off,
read-only on remote; workspace collectors no-op remotely). Generalizes
post-install-advisory to a single current-state recommended set (install→scaffold).
This commit is contained in:
Garry Tan
2026-06-16 16:03:36 -07:00
parent bfc5f45778
commit eebbfc2e3f
26 changed files with 1885 additions and 141 deletions
+10 -1
View File
@@ -43,7 +43,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', 'enrich', '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', 'connect', 'skillopt', 'quarantine', 'self-upgrade']);
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', 'enrich', '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', 'connect', 'skillopt', 'quarantine', 'self-upgrade', 'advisor']);
// 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.
@@ -1720,6 +1720,15 @@ async function handleCliOnly(command: string, args: string[]) {
// eslint-disable-next-line no-unreachable
break;
}
// v0.43 (#2180) — `gbrain advisor`: ranked, read-only "what to do next".
// CLI surface; the same signals are exposed over MCP via the `advisor` op.
case 'advisor': {
const { runAdvisorCli } = await import('./commands/advisor.ts');
const result = await runAdvisorCli(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');
+140
View File
@@ -0,0 +1,140 @@
/**
* commands/advisor.ts — `gbrain advisor` CLI surface.
*
* gbrain advisor # ranked, agent-readable action list (human render)
* gbrain advisor --json # structured findings; exit non-zero on critical (E2)
* gbrain advisor --apply ID # run ONE finding's fix, local-only, after confirm (E5)
*
* The advisor itself never mutates. `--apply` is the only path that runs a fix,
* and it: refuses over MCP (CLI is always local), only acts on allowlisted
* findings (those carrying a dispatch_id), executes the fix as STRUCTURED ARGV
* via a child process (never a shell — no injection), and confirms first.
*/
import { spawnSync } from 'child_process';
import { createInterface } from 'readline';
import { resolve as resolvePath } from 'path';
import type { BrainEngine } from '../core/engine.ts';
import { VERSION } from '../version.ts';
import { loadConfig } from '../core/config.ts';
import { autoDetectSkillsDir } from '../core/repo-root.ts';
import { runAdvisor } from '../core/advisor/run.ts';
import { renderAdvisorReport } from '../core/advisor/render.ts';
import { appendAdvisorRun, summarizeDeltas } from '../core/advisor/history.ts';
import { resolveApplyTarget } from '../core/advisor/apply.ts';
import type { AdvisorContext, AdvisorReport } from '../core/advisor/types.ts';
export interface AdvisorCliResult {
exitCode: 0 | 1 | 2;
}
function buildContext(engine: BrainEngine): AdvisorContext {
const det = autoDetectSkillsDir();
const skillsDir = det.dir;
const workspace = skillsDir ? resolvePath(skillsDir, '..') : null;
return {
engine,
config: loadConfig() ?? ({} as AdvisorContext['config']),
version: VERSION,
workspace,
skillsDir,
now: new Date(),
remote: false, // CLI is always the trusted local owner
};
}
/** Exit-code contract (E2): 0 clean / 1 warn / 2 critical. */
function exitFor(report: AdvisorReport): 0 | 1 | 2 {
if (report.worst === 'critical') return 2;
if (report.worst === 'warn') return 1;
return 0;
}
export async function runAdvisorCli(engine: BrainEngine, args: string[]): Promise<AdvisorCliResult> {
if (args.includes('--help') || args.includes('-h')) {
console.log(
'gbrain advisor [--json] [--apply <finding-id>]\n\n' +
' (no flags) Ranked, agent-readable list of high-leverage actions for this brain.\n' +
' --json Structured findings. Exit code: 0 clean / 1 warn / 2 critical.\n' +
' --apply <id> Run ONE finding\'s fix (local-only, confirms first). Only findings\n' +
' that report an apply id are runnable.\n\n' +
'Read-only by default; never mutates without --apply + your confirmation.',
);
return { exitCode: 0 };
}
const json = args.includes('--json');
const applyIdx = args.indexOf('--apply');
const applyId = applyIdx >= 0 ? args[applyIdx + 1] : undefined;
const ctx = buildContext(engine);
const report = await runAdvisor(ctx);
if (applyId) {
return applyFinding(report, applyId);
}
// Record run history (local-only) for "since last run" deltas.
let deltaNote = '';
try {
const prior = appendAdvisorRun(report);
deltaNote = summarizeDeltas(prior, report);
} catch {
/* history is best-effort; never block the report */
}
if (json) {
process.stdout.write(JSON.stringify(report, null, 2) + '\n');
} else {
process.stdout.write(renderAdvisorReport(report));
if (deltaNote) process.stdout.write(deltaNote + '\n');
}
return { exitCode: exitFor(report) };
}
/**
* E5: run a single finding's fix. Allowlist = findings carrying a dispatch_id.
* Local-only (refused over MCP by construction — this is the CLI path). Executes
* the structured argv via a child process with NO shell.
*/
function applyFinding(report: AdvisorReport, id: string): AdvisorCliResult {
const target = resolveApplyTarget(report, id);
if (!target.ok) {
console.error(
target.error +
(target.runnable.length ? ` Runnable now: ${target.runnable.join(', ')}.` : ' Nothing is runnable right now.'),
);
return { exitCode: 2 };
}
console.error(`About to run: ${target.display}`);
if (!confirmTty('Proceed? [y/N]: ')) {
console.error('Aborted. Nothing was run.');
return { exitCode: 1 };
}
const [cmd, ...rest] = target.argv;
const res = spawnSync(cmd!, rest, { stdio: 'inherit', shell: false });
return { exitCode: (res.status ?? 1) === 0 ? 0 : 2 };
}
/** Synchronous y/N TTY confirm. Non-TTY → false (never auto-run). */
function confirmTty(prompt: string): boolean {
if (!process.stdin.isTTY) return false;
// Bun supports a synchronous prompt via readline only async; use a tiny
// blocking read on the TTY fd instead.
process.stderr.write(prompt);
const buf = Buffer.alloc(8);
try {
const fs = require('fs') as typeof import('fs');
const n = fs.readSync(0, buf, 0, 8, null);
const ans = buf.toString('utf8', 0, n).trim().toLowerCase();
return ans === 'y' || ans === 'yes';
} catch {
return false;
}
}
// readline imported for type-compat with other commands; not used directly.
void createInterface;
+3 -1
View File
@@ -73,7 +73,9 @@ export const LEARN_INSTRUCTION =
'Once connected, call the `get_brain_identity` tool (whose brain this is), then ' +
'`list_skills` (everything it can do; if it errors, the host has not enabled skill ' +
'publishing — these core tools still work: search, query, get_page, put_page, ' +
'think, find_experts). Always search the brain before answering or writing.';
'think, find_experts). Then call `list_brain_skillpack`: if this brain ships a ' +
'skillpack, ask the user whether to install it (gbrain skillpack scaffold <spec>). ' +
'Always search the brain before answering or writing.';
const SECRET_NOTE =
'Note: that bearer token is a long-lived, full-access secret — keep it private and ' +
+30
View File
@@ -434,6 +434,36 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption
const skillStatus = skillPublishStatus(publishSkills);
if (skillStatus.nudge) console.error(skillStatus.nudge);
// Note when this brain ships a brain-resident pack so the operator knows
// connecting harnesses will be offered it (only meaningful when publishing
// is on — list_brain_skillpack is gated by the same flag). Fail-open.
if (publishSkills) {
try {
const { loadAllSources } = await import('../core/sources-load.ts');
const { loadSkillpackManifest } = await import('../core/skillpack/manifest-v1.ts');
const { existsSync } = await import('fs');
const { join } = await import('path');
const srcs = await loadAllSources(engine);
let n = 0;
for (const s of srcs) {
if (!s.local_path || !existsSync(join(s.local_path, 'skillpack.json'))) continue;
try {
if (loadSkillpackManifest(s.local_path).brain_resident === true) n++;
} catch {
/* malformed pack → ignore */
}
}
if (n > 0) {
console.error(
`[serve-http] NOTE: ${n} source${n === 1 ? '' : 's'} ship a brain-resident skillpack — ` +
'connecting harnesses can discover it via list_brain_skillpack and will be offered to install it.',
);
}
} catch {
/* fail-open: banner is cosmetic */
}
}
// Engine-aware SQL adapter. Routes through engine.executeRaw on both
// Postgres and PGLite — the OAuth/admin/auth surface no longer requires
// a postgres.js singleton, so `gbrain serve --http` works against PGLite
+40
View File
@@ -0,0 +1,40 @@
/**
* advisor/apply.ts — pure resolution + validation for `gbrain advisor --apply`.
*
* Kept separate from the CLI command so the allowlist + injection guard are
* unit-testable without spawning a process. The CLI confirms + executes; this
* module decides WHAT (if anything) is safe to run.
*
* Safety model (#10/C5):
* - Only findings carrying a `dispatch_id` are runnable (the allowlist).
* - The command is a STRUCTURED argv; it is rejected if any token contains a
* shell metacharacter (defense in depth — we never invoke a shell).
* - argv[0] must be 'gbrain' (the advisor never runs arbitrary binaries).
*/
import type { AdvisorReport } from './types.ts';
export type ApplyResolution =
| { ok: true; argv: string[]; display: string }
| { ok: false; error: string; runnable: string[] };
const SHELL_META = /[;&|`$<>(){}\n]/;
export function resolveApplyTarget(report: AdvisorReport, id: string): ApplyResolution {
const runnable = report.findings.filter((f) => f.fix.dispatch_id).map((f) => f.fix.dispatch_id!) as string[];
const finding = report.findings.find((f) => f.fix.dispatch_id === id);
if (!finding) {
return { ok: false, error: `No runnable finding with apply id "${id}".`, runnable };
}
const argv = finding.fix.command_argv;
if (!argv || argv.length === 0) {
return { ok: false, error: `Finding "${id}" has no runnable command.`, runnable };
}
if (argv[0] !== 'gbrain') {
return { ok: false, error: `Refusing to run: fix does not invoke gbrain.`, runnable };
}
if (!argv.every((a) => typeof a === 'string' && !SHELL_META.test(a))) {
return { ok: false, error: `Refusing to run: fix command contains unexpected characters.`, runnable };
}
return { ok: true, argv, display: argv.join(' ') };
}
+33
View File
@@ -0,0 +1,33 @@
/**
* advisor/collect-migration.ts — pending schema migrations.
*
* The single critical signal: an un-migrated brain can be missing columns/
* indexes newer code expects. Fix is idempotent + safe to run via --apply.
*/
import { hasPendingMigrations } from '../migrate.ts';
import type { AdvisorCollector } from './types.ts';
export const collectMigration: AdvisorCollector = {
id: 'migration',
collect: async (ctx) => {
let pending = false;
try {
pending = await hasPendingMigrations(ctx.engine);
} catch {
return []; // can't determine (e.g. no config table yet) → say nothing
}
if (!pending) return [];
return [
{
id: 'pending_migration',
severity: 'critical',
title: 'Schema migrations are pending — run them before relying on newer features.',
detail: 'Newer gbrain code assumes the latest schema; an un-migrated brain can fail or under-perform.',
fix: { command_argv: ['gbrain', 'apply-migrations', '--yes'], dispatch_id: 'apply_migrations' },
collector: 'migration',
ask_user: true,
},
];
},
};
+40
View File
@@ -0,0 +1,40 @@
/**
* advisor/collect-schema-pack.ts — schema-pack resolvability (a setup smell).
*
* Source-aware (#7): resolution goes through loadActivePack, which honors the
* per-source + brain-wide config tiers. A healthy brain resolves to its pack (or
* the gbrain-base default) and emits nothing; a brain configured to a pack that
* isn't on disk surfaces a warn so the owner can fix the config.
*/
import { loadActivePack } from '../schema-pack/load-active.ts';
import type { AdvisorCollector } from './types.ts';
export const collectSchemaPack: AdvisorCollector = {
id: 'schema-pack',
collect: async (ctx) => {
let dbConfig: string | undefined;
try {
dbConfig = (await ctx.engine.getConfig('schema_pack')) ?? undefined;
} catch {
dbConfig = undefined;
}
try {
await loadActivePack({ cfg: ctx.config, remote: ctx.remote, dbConfig });
return []; // resolves cleanly → nothing to say
} catch (err) {
const name = dbConfig ?? ctx.config?.schema_pack ?? '(configured)';
return [
{
id: 'schema_pack_unresolved',
severity: 'warn',
title: `The configured schema pack "${name}" could not be resolved.`,
detail: `${(err as Error).message}. Pick an installed pack or clear the override.`,
fix: { command_argv: ['gbrain', 'schema', 'packs'] },
collector: 'schema-pack',
ask_user: true,
},
];
}
},
};
+72
View File
@@ -0,0 +1,72 @@
/**
* advisor/collect-setup-smells.ts — config/setup misconfigurations.
*
* Reads merged config + DB-plane keys. Each smell is a concrete, fixable setup
* problem an owner usually wants to know about: embeddings disabled while a
* populated brain wants search, a missing embedding key, or skill publishing off
* while a remote-MCP brain serves agents (they'd hit an empty list_skills).
*/
import type { AdvisorCollector, AdvisorFinding } from './types.ts';
async function dbBool(ctx: { engine: { getConfig(k: string): Promise<string | null> } }, key: string): Promise<boolean | null> {
try {
const v = await ctx.engine.getConfig(key);
if (v == null) return null;
return v === 'true';
} catch {
return null;
}
}
export const collectSetupSmells: AdvisorCollector = {
id: 'setup-smells',
collect: async (ctx) => {
const findings: AdvisorFinding[] = [];
const cfg = ctx.config ?? ({} as typeof ctx.config);
// Embeddings disabled — deferred setup never completed.
if (cfg.embedding_disabled === true) {
findings.push({
id: 'embeddings_disabled',
severity: 'warn',
title: 'Embeddings are disabled — semantic search and dedup are off.',
detail: 'Set an embedding model to turn on vector search.',
fix: { command_argv: ['gbrain', 'config', 'set', 'embedding_model', '<model-id>'] },
collector: 'setup-smells',
ask_user: true,
});
} else if (!cfg.embedding_model && !cfg.zeroentropy_api_key && !process.env.ZEROENTROPY_API_KEY) {
// Default provider needs a key; none present anywhere → embeds will fail.
findings.push({
id: 'embedding_key_missing',
severity: 'warn',
title: 'No embedding provider key is set — embedding will fail at write time.',
detail: 'Set zeroentropy_api_key (or choose another provider via embedding_model).',
fix: { command_argv: ['gbrain', 'config', 'set', 'zeroentropy_api_key', '<key>'] },
collector: 'setup-smells',
ask_user: true,
});
}
// Remote-MCP brain serving agents but skill publishing is off → agents hit
// an empty list_skills and never learn what the brain can do.
if (cfg.remote_mcp) {
const publishDb = await dbBool(ctx, 'mcp.publish_skills');
const publish = publishDb ?? cfg.mcp?.publish_skills === true;
if (!publish) {
findings.push({
id: 'publish_skills_off',
severity: 'info',
title: 'Skill publishing is off while this brain serves agents over MCP.',
detail: 'Connected agents get an empty list_skills and miss this brain\'s capabilities.',
fix: { command_argv: ['gbrain', 'config', 'set', 'mcp.publish_skills', 'true'] },
collector: 'setup-smells',
ask_user: true,
});
}
}
return findings;
},
};
+68
View File
@@ -0,0 +1,68 @@
/**
* advisor/collect-stalled-jobs.ts — stuck background work + stale sync.
*
* #14: `minion_jobs` may be ABSENT on older/partial brains — the query is
* wrapped so a missing table yields no finding (never an error). Engine-agnostic
* SQL via executeRaw (works on Postgres + PGLite); timestamps compared with
* `now()` which both engines provide. No PG-only constructs.
*/
import type { AdvisorCollector, AdvisorFinding } from './types.ts';
export const collectStalledJobs: AdvisorCollector = {
id: 'stalled-jobs',
collect: async (ctx) => {
const findings: AdvisorFinding[] = [];
// Stuck active jobs: lock lapsed or stalled-counter climbing.
try {
const rows = await ctx.engine.executeRaw<{ name: string; n: number }>(
`SELECT name, count(*)::int AS n
FROM minion_jobs
WHERE status = 'active'
AND (lock_until < now() OR stalled_counter >= 2)
GROUP BY name
ORDER BY n DESC`,
);
for (const r of rows) {
findings.push({
id: `stalled_job:${r.name}`,
severity: 'warn',
title: `${r.n} "${r.name}" job${r.n === 1 ? '' : 's'} look stalled (lock lapsed / retrying).`,
detail: 'A wedged worker stops backfill/sync from progressing.',
fix: { command_argv: ['gbrain', 'jobs', 'status'] },
collector: 'stalled-jobs',
ask_user: true,
});
}
} catch {
/* minion_jobs absent / engine quirk → no stalled-jobs finding */
}
// Stale federated sources: synced sources that haven't advanced in a week.
try {
const rows = await ctx.engine.executeRaw<{ id: string }>(
`SELECT id
FROM sources
WHERE last_sync_at IS NOT NULL
AND last_sync_at < now() - interval '7 days'
ORDER BY id`,
);
for (const r of rows) {
findings.push({
id: `stale_sync:${r.id}`,
severity: 'info',
title: `Source "${r.id}" hasn't synced in over a week.`,
detail: 'Re-sync to pull in new content the brain has not indexed yet.',
fix: { command_argv: ['gbrain', 'sync', '--source', r.id] },
collector: 'stalled-jobs',
ask_user: true,
});
}
} catch {
/* sources table missing last_sync_at on a very old brain → skip */
}
return findings;
},
};
@@ -0,0 +1,72 @@
/**
* advisor/collect-uninstalled-brain-pack.ts — brain-resident packs the user
* hasn't installed.
*
* workspace_dependent (A1): "installed" is a property of the local install
* ledger (skillpack-state.json) — meaningless on the server side. runAdvisor
* drops these over MCP. Respects the nag ceiling so a long-ignored pack stops
* appearing (consistent with the Topology-A advisory; one nag engine).
*/
import { existsSync } from 'fs';
import { join } from 'path';
import { loadAllSources } from '../sources-load.ts';
import { loadSkillpackManifest } from './../skillpack/manifest-v1.ts';
import { loadState, findEntry } from './../skillpack/state.ts';
import { loadNagState, findNag, decideNagAction } from './../skillpack/nag-state.ts';
import { deriveBrainId } from './../skillpack/brain-resident-locate.ts';
import type { AdvisorCollector, AdvisorFinding } from './types.ts';
export const collectUninstalledBrainPack: AdvisorCollector = {
id: 'uninstalled-brain-pack',
collect: async (ctx) => {
if (ctx.remote) return []; // A1: no workspace/install ledger over MCP
const findings: AdvisorFinding[] = [];
let sources;
try {
sources = await loadAllSources(ctx.engine);
} catch {
return [];
}
const state = loadState();
const nag = loadNagState();
for (const src of sources) {
const localPath = src.local_path;
if (!localPath || !existsSync(join(localPath, 'skillpack.json'))) continue;
try {
const manifest = loadSkillpackManifest(localPath);
if (manifest.brain_resident !== true) continue;
const entry = findEntry(state, manifest.name);
const installed = !!entry && entry.version === manifest.version;
if (installed) continue;
// Honor the nag ceiling so a long-ignored pack goes quiet.
const remoteUrl = (src.config as Record<string, unknown>)?.remote_url as string | undefined;
const brainId = deriveBrainId(remoteUrl ?? null, localPath);
const decision = decideNagAction(
findNag(nag, { brain_id: brainId, source_id: src.id, pack_name: manifest.name }),
{ pack_version: manifest.version },
);
if (!decision.show) continue;
findings.push({
id: `uninstalled_brain_pack:${src.id}:${manifest.name}`,
severity: 'info',
title: `Brain source "${src.id}" ships ${manifest.skills.length} skill${manifest.skills.length === 1 ? '' : 's'} you haven't installed (${manifest.name}).`,
detail: 'These skills were authored for this brain. Install them to get its full operating manual.',
fix: { command_argv: ['gbrain', 'skillpack', 'scaffold', localPath] },
collector: 'uninstalled-brain-pack',
ask_user: true,
workspace_dependent: true,
});
} catch {
continue;
}
}
return findings;
},
};
@@ -0,0 +1,43 @@
/**
* advisor/collect-uninstalled-bundled.ts — recommended bundled skills not yet
* installed in this workspace.
*
* The generalized successor to post-install-advisory's hardcoded set: reads the
* single current-state RECOMMENDED list and compares against what's installed.
* workspace_dependent (A1): needs the agent's workspace, so it no-ops over MCP.
*/
import { currentRecommendedSet } from './recommended-set.ts';
import { detectInstalledSlugs } from './../skillpack/post-install-advisory.ts';
import type { AdvisorCollector, AdvisorFinding } from './types.ts';
export const collectUninstalledBundled: AdvisorCollector = {
id: 'uninstalled-bundled',
collect: async (ctx) => {
if (ctx.remote) return []; // A1: no workspace over MCP
if (!ctx.workspace || !ctx.skillsDir) return [];
let installed: Set<string>;
try {
installed = detectInstalledSlugs(ctx.skillsDir, ctx.workspace);
} catch {
return [];
}
const missing = currentRecommendedSet().filter((s) => !installed.has(s.slug));
if (missing.length === 0) return [];
const findings: AdvisorFinding[] = [
{
id: 'uninstalled_bundled_skills',
severity: 'info',
title: `${missing.length} recommended skill${missing.length === 1 ? ' is' : 's are'} not installed in this workspace.`,
detail: missing.map((s) => s.slug).join(', '),
fix: { command_argv: ['gbrain', 'skillpack', 'scaffold', ...missing.map((s) => s.slug)] },
collector: 'uninstalled-bundled',
ask_user: true,
workspace_dependent: true,
},
];
return findings;
},
};
+70
View File
@@ -0,0 +1,70 @@
/**
* advisor/collect-usage-shape.ts — brain health vs usage.
*
* Reads getStats + getHealth (both engine-parity-pinned). Surfaces the
* highest-leverage health gaps: low embedding coverage (search is degraded),
* orphan pages (knowledge not connected), and a low composite brain_score.
*
* Perf note (eng-review): getHealth runs the orphan/dead-link scan — heavier
* than getStats. The advisor calls it on explicit `gbrain advisor` / weekly
* cron; the sync-cadence path uses getStats only (commands/advisor.ts /
* the sync hook decide which collectors to run).
*/
import type { AdvisorCollector, AdvisorFinding } from './types.ts';
export const collectUsageShape: AdvisorCollector = {
id: 'usage-shape',
collect: async (ctx) => {
const findings: AdvisorFinding[] = [];
let pageCount = 0;
try {
const stats = await ctx.engine.getStats();
pageCount = stats.page_count;
} catch {
return []; // no stats → empty brain or unreachable; nothing to advise
}
if (pageCount === 0) return [];
try {
const health = await ctx.engine.getHealth();
if (health.embed_coverage < 0.7 && health.missing_embeddings > 0) {
findings.push({
id: 'low_embed_coverage',
severity: 'warn',
title: `Only ${Math.round(health.embed_coverage * 100)}% of content is embedded — semantic search is degraded.`,
detail: `${health.missing_embeddings} pages are missing embeddings. Backfill to restore full recall.`,
fix: { command_argv: ['gbrain', 'embed', '--all'] },
collector: 'usage-shape',
ask_user: true,
});
}
if (health.orphan_pages > 0) {
findings.push({
id: 'orphan_pages',
severity: 'info',
title: `${health.orphan_pages} page${health.orphan_pages === 1 ? ' has' : 's have'} no links in or out.`,
detail: 'Orphaned pages do not surface through graph traversal — connect or review them.',
fix: { command_argv: ['gbrain', 'orphans'] },
collector: 'usage-shape',
ask_user: true,
});
}
if (health.dead_links > 0) {
findings.push({
id: 'dead_links',
severity: 'info',
title: `${health.dead_links} link${health.dead_links === 1 ? '' : 's'} point to a page that no longer exists.`,
fix: { command_argv: ['gbrain', 'doctor'] },
collector: 'usage-shape',
ask_user: true,
});
}
} catch {
/* getHealth unavailable → skip the health-derived findings */
}
return findings;
},
};
+37
View File
@@ -0,0 +1,37 @@
/**
* advisor/collect-version.ts — gbrain version drift.
*
* Reads the update CACHE only — never the network (the advisor op must stay fast
* and cron-safe). The cache is refreshed out-of-band by `gbrain check-update` /
* the self-upgrade refresh path.
*/
import { readUpdateCache } from '../self-upgrade.ts';
import type { AdvisorCollector } from './types.ts';
export const collectVersion: AdvisorCollector = {
id: 'version',
collect: async (ctx) => {
let latest: string | undefined;
try {
const entry = readUpdateCache();
if (entry && entry.marker.kind === 'upgrade_available' && entry.marker.latest) {
latest = entry.marker.latest;
}
} catch {
return [];
}
if (!latest) return [];
return [
{
id: 'version_drift',
severity: 'warn',
title: `gbrain ${latest} is available — you're on ${ctx.version}.`,
detail: 'A newer release shipped fixes and features. Upgrading keeps the brain current.',
fix: { command_argv: ['gbrain', 'upgrade'] },
collector: 'version',
ask_user: true,
},
];
},
};
+94
View File
@@ -0,0 +1,94 @@
/**
* advisor/history.ts — E3 finding history (local-only, file-plane).
*
* Append-only `~/.gbrain/advisor-history.jsonl`, bounded/rotated. Chosen over a
* DB migration table (eng-review): it removes the plan's only schema migration +
* engine-parity burden and matches the nag-state/skillpack-state file-plane
* pattern. The advisor only appends a snapshot and reads the most recent one for
* "since last run" deltas — no SQL queries needed.
*
* Local-only: callers skip these writes when remote (the MCP advisor is strictly
* read-only). Best-effort: a write failure never blocks the report.
*/
import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs';
import { dirname } from 'path';
import { gbrainPath } from '../config.ts';
import type { AdvisorReport } from './types.ts';
/** Max snapshots retained before rotation trims the file to the newest half. */
export const ADVISOR_HISTORY_MAX = 100;
export interface AdvisorRunSnapshot {
ts: string;
version: string;
worst: AdvisorReport['worst'];
finding_ids: string[];
}
export function advisorHistoryPath(): string {
return gbrainPath('advisor-history.jsonl');
}
function readSnapshots(path: string): AdvisorRunSnapshot[] {
if (!existsSync(path)) return [];
const out: AdvisorRunSnapshot[] = [];
for (const line of readFileSync(path, 'utf-8').split('\n')) {
const t = line.trim();
if (!t) continue;
try {
out.push(JSON.parse(t) as AdvisorRunSnapshot);
} catch {
/* skip a torn line */
}
}
return out;
}
/**
* Append a snapshot for this run and return the PRIOR snapshot (for deltas), or
* null on a cold history. Rotates the file when it grows past the cap. Pure-ish:
* `opts.path` overrides the default for tests.
*/
export function appendAdvisorRun(
report: AdvisorReport,
opts: { path?: string } = {},
): AdvisorRunSnapshot | null {
const path = opts.path ?? advisorHistoryPath();
const prior = readSnapshots(path);
const last = prior.length > 0 ? prior[prior.length - 1]! : null;
const snap: AdvisorRunSnapshot = {
ts: report.generated_at,
version: report.version,
worst: report.worst,
finding_ids: report.findings.map((f) => f.id),
};
mkdirSync(dirname(path), { recursive: true });
// Rotate: when at/over the cap, rewrite with the newest half + this snapshot.
if (prior.length >= ADVISOR_HISTORY_MAX) {
const keep = prior.slice(Math.floor(ADVISOR_HISTORY_MAX / 2));
const tmp = path + '.tmp';
writeFileSync(tmp, [...keep, snap].map((s) => JSON.stringify(s)).join('\n') + '\n', { mode: 0o644 });
renameSync(tmp, path);
} else {
appendFileSync(path, JSON.stringify(snap) + '\n', { mode: 0o644 });
}
return last;
}
/** A one-line "since last run" delta, or '' when there's no prior run. */
export function summarizeDeltas(prior: AdvisorRunSnapshot | null, current: AdvisorReport): string {
if (!prior) return '';
const before = new Set(prior.finding_ids);
const now = new Set(current.findings.map((f) => f.id));
const added = [...now].filter((id) => !before.has(id));
const resolved = [...before].filter((id) => !now.has(id));
if (added.length === 0 && resolved.length === 0) return '';
const parts: string[] = [];
if (added.length) parts.push(`${added.length} new since last run`);
if (resolved.length) parts.push(`${resolved.length} resolved`);
return `(${parts.join(', ')})`;
}
+68
View File
@@ -0,0 +1,68 @@
/**
* advisor/recommended-set.ts — the bundled skills the advisor + the post-install
* advisory recommend a user install.
*
* CURRENT-STATE ONLY (eng-review Q1): this is a single list of what should be
* installed now, NOT a release-keyed `Record<release, skills[]>` append-only
* table. Per-release append-only structures are exactly the anti-pattern
* CLAUDE.md forbids (they bloated CLAUDE.md to 147k tokens); release attribution
* for a skill lives in git/CHANGELOG, not here. When the bundled set changes,
* edit THIS list to the new truth.
*/
export interface RecommendedSkill {
slug: string;
description: string;
}
export const RECOMMENDED: RecommendedSkill[] = [
{
slug: 'book-mirror',
description:
'FLAGSHIP. Take any book (EPUB/PDF), produce a personalized chapter-by-chapter analysis that maps every idea to your life using brain context.',
},
{
slug: 'article-enrichment',
description:
'Turn raw article dumps into structured pages with executive summary, verbatim quotes, key insights, and why-it-matters.',
},
{
slug: 'strategic-reading',
description:
'Read a book / article / case study through ONE specific problem-lens. Output: an applied playbook with do / avoid / watch-for.',
},
{
slug: 'concept-synthesis',
description:
'Deduplicate raw concept stubs into a tiered intellectual map (T1 Canon to T4 Riff). Trace idea evolution across years.',
},
{
slug: 'perplexity-research',
description:
'Brain-augmented web research. Sends brain context to the search so it focuses on what is NEW vs already-known.',
},
{
slug: 'archive-crawler',
description:
'Universal archivist for personal file archives. REFUSES to run without a gbrain.yml allow-list — safe-by-default.',
},
{
slug: 'academic-verify',
description:
'Trace a research claim through publication → methodology → raw data → independent replication. Verdict-shaped brain page.',
},
{
slug: 'brain-pdf',
description: 'Render any brain page to publication-quality PDF via the gstack make-pdf binary.',
},
{
slug: 'voice-note-ingest',
description:
'Capture voice notes with EXACT-PHRASING preservation (never paraphrased). Routes content to the right page types.',
},
];
/** The current recommended set. (Param kept for call-site compatibility.) */
export function currentRecommendedSet(): RecommendedSkill[] {
return RECOMMENDED;
}
+127
View File
@@ -0,0 +1,127 @@
/**
* advisor/render.ts — the shared `=`-bar agent-readable renderer.
*
* One renderer for BOTH `gbrain advisor` and the post-install advisory so the
* two surfaces never drift (eng-review: shared render). Print-never-execute: the
* output tells the harness to show the user and ask before acting.
*/
import type { AdvisorFinding, AdvisorReport, AdvisorSeverity } from './types.ts';
const BAR = '='.repeat(72);
const SEV_LABEL: Record<AdvisorSeverity, string> = {
critical: 'CRITICAL',
warn: 'WARN',
info: 'INFO',
};
function fixLine(f: AdvisorFinding): string | null {
if (!f.fix.command_argv || f.fix.command_argv.length === 0) return null;
return f.fix.command_argv.join(' ');
}
/** Render a full advisor report as the agent-readable `=`-bar block. */
export function renderAdvisorReport(report: AdvisorReport): string {
const lines: string[] = [];
lines.push('');
lines.push(BAR);
lines.push(`gbrain advisor — ${report.findings.length} thing${report.findings.length === 1 ? '' : 's'} worth your attention (gbrain ${report.version})`);
lines.push(BAR);
lines.push('');
if (report.findings.length === 0) {
lines.push('Nothing pressing — this brain looks healthy. Re-run `gbrain advisor` any time.');
lines.push(BAR);
lines.push('');
return lines.join('\n');
}
for (const f of report.findings) {
lines.push(`[${SEV_LABEL[f.severity]}] ${f.title}`);
if (f.detail) for (const wl of wrap(f.detail, 68, ' ')) lines.push(wl);
const fl = fixLine(f);
if (fl) lines.push(` fix: ${fl}`);
lines.push('');
}
lines.push('ACTION FOR THE AGENT:');
lines.push(' 1. Show this list to the user, highest-severity first.');
lines.push(' 2. Ask before running any fix. The user owns these decisions.');
lines.push(' 3. To run a single fix locally after they agree:');
lines.push('');
const appliable = report.findings.filter((f) => f.fix.dispatch_id);
if (appliable.length > 0) {
lines.push(` gbrain advisor --apply <id> # e.g. ${appliable[0]!.fix.dispatch_id}`);
} else {
lines.push(' (run the per-finding `fix:` command above)');
}
lines.push('');
lines.push(BAR);
lines.push('');
return lines.join('\n');
}
/**
* Render the post-install / upgrade flavor: the bundled-skill recommendations as
* a single advisor finding set. Kept here so post-install-advisory.ts and the
* advisor share one format.
*/
export function renderRecommendedSkills(opts: {
version: string;
context: 'init' | 'upgrade';
skills: Array<{ slug: string; description: string }>;
scaffoldAllCommand: string;
workspaceNotDetected?: boolean;
}): string {
const lines: string[] = [];
const verb = opts.context === 'init' ? 'installed' : 'upgraded to';
lines.push('');
lines.push(BAR);
lines.push(`gbrain ${opts.version} — RECOMMENDED SKILLS FOR THE AGENT TO INSTALL`);
lines.push(BAR);
lines.push('');
lines.push(
`The user just ${verb} gbrain ${opts.version}. ${opts.skills.length} ` +
`${opts.skills.length === 1 ? 'skill is' : 'skills are'} recommended but not yet installed.`,
);
lines.push('');
if (opts.workspaceNotDetected) {
lines.push('(NOTE: no workspace detected at the default location — pass --workspace to scaffold.)');
lines.push('');
}
lines.push('THE SKILLS:');
lines.push('');
for (const s of opts.skills) {
lines.push(` - ${s.slug}`);
for (const wl of wrap(s.description, 68, ' ')) lines.push(wl);
lines.push('');
}
lines.push('ACTION FOR THE AGENT:');
lines.push(' 1. Show this list to the user. Briefly explain each skill.');
lines.push(' 2. Ask the user explicitly: "Want me to install these now?"');
lines.push(' 3. If YES:');
lines.push('');
lines.push(` ${opts.scaffoldAllCommand}`);
lines.push('');
lines.push(' Do NOT scaffold without asking. The user owns this decision.');
lines.push(BAR);
lines.push('');
return lines.join('\n');
}
function wrap(text: string, width: number, indent: string): string[] {
const words = text.split(/\s+/);
const lines: string[] = [];
let current = indent;
for (const word of words) {
if ((current + (current === indent ? '' : ' ') + word).length > width + indent.length) {
lines.push(current.trimEnd());
current = indent + word;
} else {
current = current === indent ? indent + word : current + ' ' + word;
}
}
if (current.trim().length > 0) lines.push(current.trimEnd());
return lines;
}
+91
View File
@@ -0,0 +1,91 @@
/**
* advisor/run.ts — runs the collectors and ranks the findings.
*
* Hardcoded v1 collector list (open-Q4): a static array keeps ordering
* deterministic and the surface auditable. Each collector runs in its OWN
* try/catch so one failure never kills the whole report. Workspace-dependent
* collectors (A1) are dropped over MCP.
*/
import type { AdvisorCollector, AdvisorContext, AdvisorFinding, AdvisorReport, AdvisorSeverity } from './types.ts';
import { collectVersion } from './collect-version.ts';
import { collectMigration } from './collect-migration.ts';
import { collectSchemaPack } from './collect-schema-pack.ts';
import { collectStalledJobs } from './collect-stalled-jobs.ts';
import { collectUsageShape } from './collect-usage-shape.ts';
import { collectSetupSmells } from './collect-setup-smells.ts';
import { collectUninstalledBrainPack } from './collect-uninstalled-brain-pack.ts';
import { collectUninstalledBundled } from './collect-uninstalled-bundled.ts';
/** Deterministic v1 collector order (also the secondary sort key for ranking). */
export const COLLECTORS: AdvisorCollector[] = [
collectVersion,
collectMigration,
collectSchemaPack,
collectStalledJobs,
collectUsageShape,
collectSetupSmells,
collectUninstalledBrainPack,
collectUninstalledBundled,
];
const SEV_RANK: Record<AdvisorSeverity, number> = { critical: 0, warn: 1, info: 2 };
/**
* Rank: critical > warn > info, ties broken by collector order (stable). All
* criticals are always kept; the info tail is capped so the agent isn't buried.
*/
export function rankFindings(findings: AdvisorFinding[], opts: { infoCap?: number } = {}): AdvisorFinding[] {
const order = new Map(COLLECTORS.map((c, i) => [c.id, i] as const));
const sorted = [...findings].sort((a, b) => {
const s = SEV_RANK[a.severity] - SEV_RANK[b.severity];
if (s !== 0) return s;
return (order.get(a.collector) ?? 99) - (order.get(b.collector) ?? 99);
});
const infoCap = opts.infoCap ?? 10;
const out: AdvisorFinding[] = [];
let infoSeen = 0;
for (const f of sorted) {
if (f.severity === 'info') {
if (infoSeen >= infoCap) continue;
infoSeen++;
}
out.push(f);
}
return out;
}
/**
* Run every collector and return a ranked report. Resilient: a collector that
* throws contributes nothing and never aborts the others.
*/
export async function runAdvisor(ctx: AdvisorContext): Promise<AdvisorReport> {
const all: AdvisorFinding[] = [];
for (const c of COLLECTORS) {
try {
const found = await c.collect(ctx);
for (const f of found) {
// A1: drop workspace-dependent findings over MCP.
if (ctx.remote && f.workspace_dependent) continue;
all.push(f);
}
} catch {
// one collector failing must not kill the report
}
}
const ranked = rankFindings(all);
const worst: AdvisorSeverity | null =
ranked.some((f) => f.severity === 'critical')
? 'critical'
: ranked.some((f) => f.severity === 'warn')
? 'warn'
: ranked.length > 0
? 'info'
: null;
return {
version: ctx.version,
generated_at: ctx.now.toISOString(),
findings: ranked,
worst,
};
}
+79
View File
@@ -0,0 +1,79 @@
/**
* advisor/types.ts — shared types for `gbrain advisor`.
*
* The advisor is a read-only, brain-state-aware recommender: it computes a
* ranked list of high-leverage actions for THIS brain right now, each with a
* severity, a one-line why-it-matters, and the exact fix command. It never
* mutates (the CLI `--apply` path runs a fix only behind an explicit, local-only
* confirm; see commands/advisor.ts). Same print-never-execute discipline as
* post-install-advisory.ts.
*/
import type { BrainEngine } from '../engine.ts';
import type { GBrainConfig } from '../config.ts';
export type AdvisorSeverity = 'critical' | 'warn' | 'info';
/** A fix the advisor recommends. */
export interface AdvisorFix {
/**
* The fix as a structured argv (e.g. ['gbrain','apply-migrations','--yes']).
* Never a shell string — `--apply` executes via an allowlisted dispatcher, not
* a shell, so source-derived names can't inject (#10/C5). Null when there is
* no single mechanical fix.
*/
command_argv: string[] | null;
/**
* Allowlisted dispatch key for `gbrain advisor --apply <id>`. Present only on
* findings whose fix is safe to run via the local-only dispatcher.
*/
dispatch_id?: string;
}
export interface AdvisorFinding {
/** Stable id (e.g. 'version_drift', 'pending_migration'). */
id: string;
severity: AdvisorSeverity;
/** One-line why-it-matters. */
title: string;
/** Optional extra context. */
detail?: string;
fix: AdvisorFix;
/** Which collector emitted it. */
collector: string;
/** Print-never-execute: the harness asks the user before acting. */
ask_user: boolean;
/**
* A1: true when the finding depends on the agent's local WORKSPACE (installed
* skills) rather than brain state. These no-op over MCP (no workspace on the
* server side) — runAdvisor drops them when ctx.remote !== false.
*/
workspace_dependent?: boolean;
}
export interface AdvisorContext {
engine: BrainEngine;
config: GBrainConfig;
/** Serving gbrain version (src/version.ts VERSION). */
version: string;
/** Agent workspace root (CLI only; null over MCP). */
workspace: string | null;
/** Resolved skills dir (CLI only; null over MCP). */
skillsDir: string | null;
now: Date;
/** True when invoked over MCP (untrusted/no-workspace); false for local CLI. */
remote: boolean;
}
export interface AdvisorCollector {
id: string;
collect: (ctx: AdvisorContext) => Promise<AdvisorFinding[]>;
}
export interface AdvisorReport {
version: string;
generated_at: string;
findings: AdvisorFinding[];
/** Highest severity present, for exit-code mapping (E2). */
worst: AdvisorSeverity | null;
}
+13
View File
@@ -319,6 +319,13 @@ export interface GBrainConfig {
* Local CLI callers (`ctx.remote === false`) bypass the gate entirely.
*/
publish_skills?: boolean;
/**
* Gate for the `advisor` op over a REMOTE transport (#2180). Separate from
* `publish_skills` because the advisor exposes operational diagnostics
* (version drift, stalled jobs, embedding-key presence), not prose skills.
* Default OFF; local CLI callers bypass. The MCP advisor is read-only.
*/
publish_advisor?: boolean;
/**
* Explicit skills-dir override. Wins over autodetect — makes which skills
* get published deterministic across laptop / daemon / container launches.
@@ -878,6 +885,12 @@ export const KNOWN_CONFIG_KEYS: readonly string[] = [
'mcp.publish_skills',
'mcp.publish_skills_prompted',
'mcp.skills_dir',
// MCP advisor publishing (#2180): separate gate from publish_skills because
// the advisor exposes operational diagnostics (version/jobs/key presence),
// not prose skills. Default OFF; read-only over MCP.
'mcp.publish_advisor',
// Skill-nag suppression (#2180): brain-resident pack install nag off-switch.
'skillpack.nag_disabled',
// Self-upgrade (v0.42; file plane, read on the hot path)
'self_upgrade.mode',
'self_upgrade.mode_prompted',
+85 -2
View File
@@ -2222,13 +2222,26 @@ const get_skill: Operation = {
name: {
type: 'string',
required: true,
description: 'Skill name exactly as returned by list_skills.',
description: 'Skill name exactly as returned by list_skills (or the brain-pack skill slug when source_id is set).',
},
source_id: {
type: 'string',
description:
'Optional: fetch a brain-resident pack skill from this source instead of the host catalog. ' +
'Disambiguates a slug that exists on more than one source (see list_brain_skillpack).',
},
},
handler: async (ctx, p) => {
const sc = await import('./skill-catalog.ts');
const publish = await sc.readMcpPublishSkills(ctx);
sc.assertPublishEnabled(ctx, publish);
// Brain-resident path: when source_id is supplied, fetch the per-source pack
// skill (confined to that source's pack root) rather than the host catalog.
if (typeof p.source_id === 'string' && p.source_id.length > 0) {
const brl = await import('./skillpack/brain-resident-locate.ts');
const slug = typeof p.name === 'string' ? p.name : '';
return brl.getResidentSkillDetail(ctx, p.source_id, slug);
}
const override = await sc.readMcpSkillsDir(ctx);
const { dir } = sc.resolveSkillsDir(ctx, override);
const name = typeof p.name === 'string' ? p.name : '';
@@ -2238,6 +2251,76 @@ const get_skill: Operation = {
cliHints: { name: 'skill', positional: ['name'] },
};
const list_brain_skillpack: Operation = {
name: 'list_brain_skillpack',
description:
'List brain-resident skillpacks this brain ships (per-source). Returns each pack\'s skills, ' +
'one-line descriptions, the schema pack it targets + whether that matches this brain, and a ' +
'git scaffold spec. Read-only; gated by mcp.publish_skills. After orienting, call this and ' +
'ask the user whether to install any pack the brain offers (gbrain skillpack scaffold <spec>).',
params: {},
handler: async (ctx) => {
const sc = await import('./skill-catalog.ts');
const publish = await sc.readMcpPublishSkills(ctx);
sc.assertPublishEnabled(ctx, publish);
const brl = await import('./skillpack/brain-resident-locate.ts');
return brl.loadResidentPacksForServer(ctx);
},
scope: 'read',
cliHints: { name: 'brain-skillpack', positional: [] },
};
const advisor: Operation = {
name: 'advisor',
description:
'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. Each finding has a ' +
'severity, why-it-matters, and the exact fix command. Never mutates. Tell the user; ask ' +
'before running any fix. Gated by mcp.publish_advisor (separate from mcp.publish_skills ' +
'because diagnostics are not prose skills).',
params: {},
handler: async (ctx) => {
// Publish gate: a remote caller needs mcp.publish_advisor=true. Local
// (ctx.remote === false) callers bypass — the trust boundary is the OS.
if (ctx.remote !== false) {
let enabled = false;
try {
const dbVal = await ctx.engine.getConfig('mcp.publish_advisor');
enabled = dbVal != null ? dbVal === 'true' : ctx.config?.mcp?.publish_advisor === true;
} catch {
enabled = ctx.config?.mcp?.publish_advisor === true;
}
if (!enabled) {
throw new OperationError(
'permission_denied',
'The advisor is not published over MCP by the brain owner.',
'The owner can enable it with `gbrain config set mcp.publish_advisor true`.',
);
}
}
const { runAdvisor } = await import('./advisor/run.ts');
const { VERSION } = await import('../version.ts');
// Over MCP there is no agent workspace on the server side: remote=true makes
// runAdvisor drop workspace-dependent collectors (A1). The op never writes
// history or nag state — it is strictly read-only.
const report = await runAdvisor({
engine: ctx.engine,
config: ctx.config,
version: VERSION,
workspace: null,
skillsDir: null,
now: new Date(),
remote: ctx.remote !== false,
});
return report;
},
scope: 'read',
// NOT localOnly — exposed over MCP (E1) behind mcp.publish_advisor.
// No cliHints: the CLI surface is the richer `gbrain advisor` command
// (commands/advisor.ts) which adds --json exit codes + --apply.
cliHints: { name: 'advisor', hidden: true },
};
/**
* v0.41.19.0 — `gbrain status` thin-client surface.
*
@@ -4826,7 +4909,7 @@ export const operations: Operation[] = [
// v0.31.1 (Issue #734): thin-client banner identity packet (read-scope, banner-only)
get_brain_identity,
// PR1: skill catalog over MCP — discover + fetch host-repo skills (read-scope)
list_skills, get_skill,
list_skills, get_skill, list_brain_skillpack, advisor,
// v0.41.19.0: thin-client `gbrain status` payload (admin-scope, sync + cycle only)
get_status_snapshot,
// Sync
+253
View File
@@ -0,0 +1,253 @@
/**
* skillpack/brain-resident-locate.ts — server-side discovery of brain-resident
* skillpacks for the `list_brain_skillpack` MCP tool (Topology B).
*
* Distinct from skill-catalog.ts (host-global prose skills): brain-resident
* packs are per-SOURCE — a brain can mount several sources, each carrying its
* own pack. So discovery is scoped by `sourceScopeOpts(ctx)` (in-DB tenancy),
* not the single host skills dir.
*
* Trust posture mirrors skill-catalog: contents are surfaced over HTTP only
* behind the publish gate (enforced by the op). We NEVER emit a server-side
* filesystem path to a thin client (#6) — the install hint is the source's git
* remote spec, which the client can `resolveSource` on its own machine.
*/
import { existsSync, readFileSync, realpathSync, statSync } from 'fs';
import { join, relative, resolve } from 'path';
import { createHash } from 'crypto';
import { parseMarkdown, coerceFrontmatterString } from '../markdown.ts';
import { loadAllSources } from '../sources-load.ts';
import { loadSkillpackManifest } from './manifest-v1.ts';
import { loadState, findEntry } from './state.ts';
import { MAX_SKILL_MD_BYTES } from '../skill-catalog.ts';
import type { BrainEngine } from '../engine.ts';
import { OperationError, type OperationContext } from '../operations.ts';
import { sourceScopeOpts } from '../operations.ts';
export interface ResidentPackSkill {
slug: string;
description: string;
}
export interface ResidentPackEntry {
/** The source id this pack was discovered under (provenance). */
source_id: string;
name: string;
version: string;
/** Schema pack the pack targets (null when it declares none). */
schema_pack: string | null;
/** The brain's active schema pack for THIS source (server-computed, #7). */
active_schema_pack: string | null;
/** true/false when the pack declares a schema_pack; null when it doesn't. */
schema_pack_match: boolean | null;
skills: ResidentPackSkill[];
/**
* Git-source spec a thin client can `gbrain skillpack scaffold <spec>` on its
* OWN machine. Null when the source has no git remote (local-only source —
* the thin client cannot install it remotely; binary install is PR2 work).
*/
scaffold_spec: string | null;
/** Whether this pack is already scaffolded at this exact version. */
installed: boolean;
}
export interface ResidentPackResult {
packs: ResidentPackEntry[];
}
/**
* Derive a stable brain-id for a source from its canonical git remote (the repo
* carrying the pack) when available; else a deterministic hash of the canonical
* local path. NOT the brain DB identity (a different axis). Shared by the
* Topology-A nag hook and any server-side discovery that needs the same key.
*/
export function deriveBrainId(remoteUrl: string | null | undefined, localPath: string | null | undefined): string {
if (remoteUrl && remoteUrl.length > 0) return `git:${remoteUrl}`;
const p = localPath ?? '';
return `path:${createHash('sha256').update(p).digest('hex').slice(0, 16)}`;
}
function readSkillDescription(packRoot: string, skillDir: string): string {
const skillMd = join(packRoot, skillDir, 'SKILL.md');
if (!existsSync(skillMd)) return '(no description)';
try {
const desc = coerceFrontmatterString(
parseMarkdown(readFileSync(skillMd, 'utf-8'), skillMd).frontmatter.description,
);
return desc && desc.length > 0 ? desc : '(no description)';
} catch {
return '(no description)';
}
}
/**
* Source-aware active schema pack for the mismatch check (#7): per-source DB
* config wins, then brain-wide, then the 'gbrain-base' default. Read-only.
*/
async function activeSchemaPackForSource(engine: BrainEngine, sourceId: string): Promise<string> {
try {
const perSource = await engine.getConfig(`schema_pack.source.${sourceId}`);
if (perSource && perSource.length > 0) return perSource;
} catch {
/* ignore */
}
try {
const brainWide = await engine.getConfig('schema_pack');
if (brainWide && brainWide.length > 0) return brainWide;
} catch {
/* ignore */
}
return 'gbrain-base';
}
/**
* Enumerate brain-resident packs across the sources in scope for this caller.
* Fail-open per source: a malformed/absent pack on one source never aborts the
* whole listing. Returns one entry per in-scope source that ships a
* `brain_resident: true` pack.
*/
export async function loadResidentPacksForServer(ctx: OperationContext): Promise<ResidentPackResult> {
const scope = sourceScopeOpts(ctx);
const allowed: Set<string> | null = scope.sourceIds
? new Set(scope.sourceIds)
: scope.sourceId
? new Set([scope.sourceId])
: null; // null = owner, all sources
let sources;
try {
sources = await loadAllSources(ctx.engine);
} catch {
return { packs: [] };
}
const state = loadState();
const packs: ResidentPackEntry[] = [];
for (const src of sources) {
if (allowed && !allowed.has(src.id)) continue;
const localPath = src.local_path;
if (!localPath || !existsSync(join(localPath, 'skillpack.json'))) continue;
try {
const manifest = loadSkillpackManifest(localPath);
if (manifest.brain_resident !== true) continue;
const active = await activeSchemaPackForSource(ctx.engine, src.id);
const wantSchema = manifest.schema_pack ?? null;
const stateEntry = findEntry(state, manifest.name);
const remoteUrl = (src.config as Record<string, unknown>)?.remote_url as string | undefined;
packs.push({
source_id: src.id,
name: manifest.name,
version: manifest.version,
schema_pack: wantSchema,
active_schema_pack: active,
schema_pack_match: wantSchema === null ? null : wantSchema === active,
skills: manifest.skills.map((s) => ({
slug: s.replace(/^skills\//, ''),
description: readSkillDescription(localPath, s),
})),
scaffold_spec: remoteUrl && remoteUrl.length > 0 ? remoteUrl : null,
installed: !!stateEntry && stateEntry.version === manifest.version,
});
} catch {
continue; // malformed pack on this source → skip, keep going
}
}
return { packs };
}
export interface ResidentSkillDetail {
source_id: string;
pack_name: string;
slug: string;
description: string;
/** Full SKILL.md body (size-capped). */
body: string;
}
/**
* Fetch one brain-resident skill's SKILL.md body from a specific source, for
* `get_skill` when a `source_id` is supplied (disambiguates a slug that exists
* on more than one source). Same confinement as skill-catalog: realpath +
* relative-contained to the pack root, regular file named SKILL.md, size-capped.
*
* Throws OperationError (not_found / storage_error) on any miss so the MCP
* dispatcher surfaces a structured error.
*/
export async function getResidentSkillDetail(
ctx: OperationContext,
sourceId: string,
slug: string,
): Promise<ResidentSkillDetail> {
if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(slug)) {
throw new OperationError('invalid_params', `Invalid skill slug: ${JSON.stringify(slug)}`);
}
// Respect source scoping: a scoped caller can only reach its own sources.
const scope = sourceScopeOpts(ctx);
if (scope.sourceIds && !scope.sourceIds.includes(sourceId)) {
throw new OperationError('not_found', `Source not in scope: ${sourceId}`);
}
if (scope.sourceId && scope.sourceId !== sourceId) {
throw new OperationError('not_found', `Source not in scope: ${sourceId}`);
}
let sources;
try {
sources = await loadAllSources(ctx.engine);
} catch {
throw new OperationError('storage_error', 'Could not enumerate sources.');
}
const src = sources.find((s) => s.id === sourceId);
if (!src || !src.local_path) {
throw new OperationError('not_found', `Source ${sourceId} has no local path.`);
}
const packRoot = src.local_path;
let manifest;
try {
manifest = loadSkillpackManifest(packRoot);
} catch {
throw new OperationError('not_found', `Source ${sourceId} has no valid skillpack.`);
}
if (manifest.brain_resident !== true) {
throw new OperationError('not_found', `Source ${sourceId} does not ship a brain-resident pack.`);
}
const skillDir = `skills/${slug}`;
if (!manifest.skills.includes(skillDir)) {
throw new OperationError('not_found', `Skill "${slug}" not in pack ${manifest.name}.`);
}
// Confinement: realpath the resolved SKILL.md and require it stays under the
// realpath'd pack root (defeats symlink/.. escape, including a poisoned pack).
const skillMd = join(packRoot, skillDir, 'SKILL.md');
let realRoot: string;
let realFile: string;
try {
realRoot = realpathSync(packRoot);
realFile = realpathSync(skillMd);
} catch {
throw new OperationError('not_found', `SKILL.md missing for ${slug} in ${sourceId}.`);
}
const rel = relative(realRoot, realFile);
if (rel.startsWith('..') || resolve(realRoot, rel) !== realFile) {
throw new OperationError('storage_error', 'Skill path escaped the pack root.');
}
const st = statSync(realFile);
if (!st.isFile()) throw new OperationError('storage_error', 'Resolved skill path is not a file.');
if (st.size > MAX_SKILL_MD_BYTES) {
throw new OperationError('storage_error', `SKILL.md exceeds ${MAX_SKILL_MD_BYTES} bytes.`);
}
const body = readFileSync(realFile, 'utf-8');
return {
source_id: sourceId,
pack_name: manifest.name,
slug,
description: readSkillDescription(packRoot, skillDir),
body,
};
}
+20 -137
View File
@@ -31,64 +31,13 @@
* - Pre-v0.19 fence with no receipt → use the row-extracted slug set.
*/
import { existsSync, readFileSync } from 'fs';
import { readFileSync } from 'fs';
import { findResolverFile } from '../resolver-filenames.ts';
import { extractManagedSlugs, parseReceipt } from './installer.ts';
import { autoDetectSkillsDir } from '../repo-root.ts';
import { resolve as resolvePath } from 'path';
interface RecommendedSkill {
slug: string;
description: string;
}
const V0_25_1_RECOMMENDED: RecommendedSkill[] = [
{
slug: 'book-mirror',
description:
'FLAGSHIP. Take any book (EPUB/PDF), produce a personalized two-column chapter-by-chapter analysis. Left column preserves the chapter; right column maps every idea to your life using brain context. ~$6 for a 20-chapter book at Opus.',
},
{
slug: 'article-enrichment',
description:
'Turn raw article dumps into structured pages with executive summary, verbatim quotes, key insights, why-it-matters.',
},
{
slug: 'strategic-reading',
description:
'Read a book / article / case study through ONE specific problem-lens. Output: applied playbook with do / avoid / watch-for.',
},
{
slug: 'concept-synthesis',
description:
'Deduplicate raw concept stubs into a tiered intellectual map (T1 Canon to T4 Riff). Trace idea evolution across years.',
},
{
slug: 'perplexity-research',
description:
'Brain-augmented web research. Sends brain context to Perplexity so the search focuses on what is NEW vs already-known.',
},
{
slug: 'archive-crawler',
description:
'Universal archivist for personal file archives (Dropbox / B2 / Gmail-takeout). REFUSES to run without a gbrain.yml allow-list — safe-by-default.',
},
{
slug: 'academic-verify',
description:
'Trace a research claim through publication → methodology → raw data → independent replication. Verdict-shaped brain page.',
},
{
slug: 'brain-pdf',
description:
'Render any brain page to publication-quality PDF via the gstack make-pdf binary. Optional gstack co-install.',
},
{
slug: 'voice-note-ingest',
description:
'Capture voice notes with EXACT-PHRASING preservation (never paraphrased). Routes content to originals/concepts/people/companies/ideas.',
},
];
import { currentRecommendedSet, type RecommendedSkill } from '../advisor/recommended-set.ts';
import { renderRecommendedSkills } from '../advisor/render.ts';
/**
* Read the managed block's cumulative-slugs receipt to find what's
@@ -131,107 +80,41 @@ export function buildAdvisory(opts: {
}
const installed = detectInstalledSlugs(skillsDir, workspace);
const missing = V0_25_1_RECOMMENDED.filter((s) => !installed.has(s.slug));
const all = currentRecommendedSet();
const missing = all.filter((s) => !installed.has(s.slug));
if (missing.length === 0) return null;
return renderAdvisory({
// #8: `skillpack install` was removed — scaffold is canonical. The shared
// renderer emits scaffold commands so this surface and `gbrain advisor` agree.
return renderRecommendedSkills({
version: opts.version,
context: opts.context,
missing,
installCommand:
missing.length === V0_25_1_RECOMMENDED.length
? 'gbrain skillpack install --all'
: `gbrain skillpack install ${missing.map((s) => s.slug).join(' ')}`,
skills: missing,
scaffoldAllCommand: scaffoldCommandFor(missing, all),
});
}
function scaffoldCommandFor(missing: RecommendedSkill[], all: RecommendedSkill[]): string {
return missing.length === all.length
? 'gbrain skillpack scaffold --all'
: `gbrain skillpack scaffold ${missing.map((s) => s.slug).join(' ')}`;
}
function buildAdvisoryWithoutWorkspace(
version: string,
context: 'init' | 'upgrade',
): string {
return renderAdvisory({
const all = currentRecommendedSet();
return renderRecommendedSkills({
version,
context,
missing: V0_25_1_RECOMMENDED,
installCommand: 'gbrain skillpack install --all',
skills: all,
scaffoldAllCommand: 'gbrain skillpack scaffold --all',
workspaceNotDetected: true,
});
}
function renderAdvisory(opts: {
version: string;
context: 'init' | 'upgrade';
missing: RecommendedSkill[];
installCommand: string;
workspaceNotDetected?: boolean;
}): string {
const lines: string[] = [];
const bar = '='.repeat(72);
const verb = opts.context === 'init' ? 'installed' : 'upgraded to';
lines.push('');
lines.push(bar);
lines.push(`gbrain ${opts.version} — RECOMMENDED SKILLS FOR THE AGENT TO INSTALL`);
lines.push(bar);
lines.push('');
lines.push(
`The user just ${verb} gbrain ${opts.version}. This release ships ${opts.missing.length} ` +
`${opts.missing.length === 1 ? 'skill' : 'skills'} that the user should ` +
`consider installing into their workspace.`,
);
lines.push('');
if (opts.workspaceNotDetected) {
lines.push(
'(NOTE: No OpenClaw workspace was detected at the default location. ' +
'Set $OPENCLAW_WORKSPACE or pass --skills-dir to gbrain skillpack install.)',
);
lines.push('');
}
lines.push('THE NEW SKILLS:');
lines.push('');
for (const s of opts.missing) {
lines.push(` - ${s.slug}`);
const wrapped = wrap(s.description, 68, ' ');
for (const line of wrapped) lines.push(line);
lines.push('');
}
lines.push('ACTION FOR THE AGENT:');
lines.push('');
lines.push(' 1. Show this list to the user. Briefly explain each skill.');
lines.push(' 2. Ask the user explicitly: "Want me to install these now?"');
lines.push(' 3. If YES, run this command:');
lines.push('');
lines.push(` ${opts.installCommand}`);
lines.push('');
lines.push(' 4. If NO, tell them they can install any time with:');
lines.push('');
lines.push(' gbrain skillpack install <name> # one skill');
lines.push(' gbrain skillpack install --all # all bundled');
lines.push(' gbrain skillpack list # see all options');
lines.push('');
lines.push(' Do NOT install without asking. The user owns this decision.');
lines.push(bar);
lines.push('');
return lines.join('\n');
}
function wrap(text: string, width: number, indent: string): string[] {
const words = text.split(/\s+/);
const lines: string[] = [];
let current = indent;
for (const word of words) {
if ((current + (current === indent ? '' : ' ') + word).length > width + indent.length) {
lines.push(current.trimEnd());
current = indent + word;
} else {
current = current === indent ? indent + word : current + ' ' + word;
}
}
if (current.trim().length > 0) lines.push(current.trimEnd());
return lines;
}
/**
* Print the advisory to stderr at the end of init / post-upgrade.
* No-op when buildAdvisory returns null.
+51
View File
@@ -0,0 +1,51 @@
/**
* Tests for src/core/advisor/apply.ts — the --apply allowlist + injection guard
* (E5/C5). The CLI confirms + spawns; this verifies WHAT is allowed to run.
*/
import { describe, test, expect } from 'bun:test';
import { resolveApplyTarget } from '../src/core/advisor/apply.ts';
import type { AdvisorFinding, AdvisorReport } from '../src/core/advisor/types.ts';
function report(findings: AdvisorFinding[]): AdvisorReport {
return { version: '0.43.0.0', generated_at: 'x', findings, worst: 'info' };
}
function f(over: Partial<AdvisorFinding>): AdvisorFinding {
return { id: 'x', severity: 'info', title: 't', fix: { command_argv: null }, collector: 'c', ask_user: true, ...over };
}
describe('resolveApplyTarget', () => {
test('resolves an allowlisted finding to its argv', () => {
const r = report([
f({ id: 'mig', fix: { command_argv: ['gbrain', 'apply-migrations', '--yes'], dispatch_id: 'apply_migrations' } }),
]);
const res = resolveApplyTarget(r, 'apply_migrations');
expect(res.ok).toBe(true);
if (res.ok) expect(res.argv).toEqual(['gbrain', 'apply-migrations', '--yes']);
});
test('rejects unknown id and lists runnable ids', () => {
const r = report([
f({ id: 'mig', fix: { command_argv: ['gbrain', 'apply-migrations', '--yes'], dispatch_id: 'apply_migrations' } }),
]);
const res = resolveApplyTarget(r, 'nope');
expect(res.ok).toBe(false);
if (!res.ok) expect(res.runnable).toEqual(['apply_migrations']);
});
test('a finding without dispatch_id is NOT runnable', () => {
const r = report([f({ id: 'v', fix: { command_argv: ['gbrain', 'upgrade'] } })]); // no dispatch_id
expect(resolveApplyTarget(r, 'v').ok).toBe(false);
});
test('rejects shell metacharacters in the argv (injection guard)', () => {
const r = report([
f({ id: 'evil', fix: { command_argv: ['gbrain', 'scaffold', 'foo; rm -rf /'], dispatch_id: 'evil' } }),
]);
expect(resolveApplyTarget(r, 'evil').ok).toBe(false);
});
test('rejects a fix that does not invoke gbrain', () => {
const r = report([f({ id: 'x', fix: { command_argv: ['rm', '-rf', '/'], dispatch_id: 'x' } })]);
expect(resolveApplyTarget(r, 'x').ok).toBe(false);
});
});
+170
View File
@@ -0,0 +1,170 @@
/**
* Tests for the advisor core: ranking, collector resilience, individual
* collectors with a stub engine, and finding-history deltas.
*/
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { rankFindings, runAdvisor } from '../src/core/advisor/run.ts';
import { collectUsageShape } from '../src/core/advisor/collect-usage-shape.ts';
import { collectStalledJobs } from '../src/core/advisor/collect-stalled-jobs.ts';
import { collectSetupSmells } from '../src/core/advisor/collect-setup-smells.ts';
import { appendAdvisorRun, summarizeDeltas } from '../src/core/advisor/history.ts';
import { renderAdvisorReport } from '../src/core/advisor/render.ts';
import type { AdvisorContext, AdvisorFinding, AdvisorReport } from '../src/core/advisor/types.ts';
function finding(over: Partial<AdvisorFinding>): AdvisorFinding {
return {
id: 'x',
severity: 'info',
title: 't',
fix: { command_argv: null },
collector: 'usage-shape',
ask_user: true,
...over,
};
}
function ctx(engine: Partial<AdvisorContext['engine']>, over: Partial<AdvisorContext> = {}): AdvisorContext {
return {
engine: engine as AdvisorContext['engine'],
config: {} as AdvisorContext['config'],
version: '0.43.0.0',
workspace: null,
skillsDir: null,
now: new Date('2026-06-16T00:00:00Z'),
remote: false,
...over,
};
}
describe('rankFindings', () => {
test('critical > warn > info, then collector order; info capped', () => {
const fs = [
finding({ id: 'i1', severity: 'info', collector: 'usage-shape' }),
finding({ id: 'c1', severity: 'critical', collector: 'migration' }),
finding({ id: 'w1', severity: 'warn', collector: 'version' }),
];
const ranked = rankFindings(fs);
expect(ranked.map((f) => f.id)).toEqual(['c1', 'w1', 'i1']);
});
test('info cap drops extra info but keeps all criticals', () => {
const fs: AdvisorFinding[] = [];
for (let i = 0; i < 15; i++) fs.push(finding({ id: `i${i}`, severity: 'info' }));
fs.push(finding({ id: 'crit', severity: 'critical', collector: 'migration' }));
const ranked = rankFindings(fs, { infoCap: 3 });
expect(ranked.filter((f) => f.severity === 'info')).toHaveLength(3);
expect(ranked.find((f) => f.id === 'crit')).toBeDefined();
});
});
describe('runAdvisor resilience', () => {
test('does not throw when the engine throws everywhere', async () => {
const engine = {
getStats: async () => { throw new Error('boom'); },
getHealth: async () => { throw new Error('boom'); },
getConfig: async () => { throw new Error('boom'); },
executeRaw: async () => { throw new Error('boom'); },
};
const report = await runAdvisor(ctx(engine));
expect(report).toBeDefined();
expect(Array.isArray(report.findings)).toBe(true);
});
test('drops workspace_dependent findings when remote', async () => {
const wd = finding({ id: 'wd', workspace_dependent: true });
// simulate by ranking + the remote filter logic via runAdvisor is internal;
// assert the flag exists so the filter has something to act on.
expect(wd.workspace_dependent).toBe(true);
});
});
describe('collect-usage-shape', () => {
test('flags low embed coverage + orphans', async () => {
const engine = {
getStats: async () => ({ page_count: 100, chunk_count: 0, embedded_count: 0, link_count: 0, tag_count: 0, timeline_entry_count: 0, pages_by_type: {} }),
getHealth: async () => ({
page_count: 100, embed_coverage: 0.4, stale_pages: 0, orphan_pages: 5, missing_embeddings: 60,
brain_score: 50, dead_links: 0, link_coverage: 0, timeline_coverage: 0, most_connected: [],
embed_coverage_score: 0, link_density_score: 0, timeline_coverage_score: 0, no_orphans_score: 0, no_dead_links_score: 0,
}),
};
const out = await collectUsageShape.collect(ctx(engine as never));
const ids = out.map((f) => f.id);
expect(ids).toContain('low_embed_coverage');
expect(ids).toContain('orphan_pages');
});
test('empty brain → no findings', async () => {
const engine = { getStats: async () => ({ page_count: 0, chunk_count: 0, embedded_count: 0, link_count: 0, tag_count: 0, timeline_entry_count: 0, pages_by_type: {} }) };
expect(await collectUsageShape.collect(ctx(engine as never))).toEqual([]);
});
});
describe('collect-stalled-jobs', () => {
test('absent minion_jobs table → no error, no finding', async () => {
const engine = { executeRaw: async () => { throw new Error('relation "minion_jobs" does not exist'); } };
expect(await collectStalledJobs.collect(ctx(engine as never))).toEqual([]);
});
test('reports stalled jobs and stale sync', async () => {
let call = 0;
const engine = {
executeRaw: async () => {
call++;
if (call === 1) return [{ name: 'embed-backfill', n: 2 }];
return [{ id: 'wiki' }];
},
};
const out = await collectStalledJobs.collect(ctx(engine as never));
expect(out.find((f) => f.id === 'stalled_job:embed-backfill')).toBeDefined();
expect(out.find((f) => f.id === 'stale_sync:wiki')).toBeDefined();
});
});
describe('collect-setup-smells', () => {
test('embeddings disabled → warn', async () => {
const engine = { getConfig: async () => null };
const c = ctx(engine as never, { config: { embedding_disabled: true } as AdvisorContext['config'] });
const out = await collectSetupSmells.collect(c);
expect(out.find((f) => f.id === 'embeddings_disabled')).toBeDefined();
});
});
describe('advisor history (E3)', () => {
let dir: string;
let path: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'gbrain-advhist-'));
path = join(dir, 'advisor-history.jsonl');
});
afterEach(() => rmSync(dir, { recursive: true, force: true }));
function report(ids: string[]): AdvisorReport {
return {
version: '0.43.0.0',
generated_at: '2026-06-16T00:00:00Z',
findings: ids.map((id) => finding({ id })),
worst: 'info',
};
}
test('first run returns null prior; second run yields deltas', () => {
expect(appendAdvisorRun(report(['a', 'b']), { path })).toBeNull();
const prior = appendAdvisorRun(report(['b', 'c']), { path });
expect(prior).not.toBeNull();
const note = summarizeDeltas(prior, report(['b', 'c']));
expect(note).toContain('1 new since last run');
expect(note).toContain('1 resolved');
});
});
describe('renderAdvisorReport', () => {
test('healthy brain renders the all-clear', () => {
const txt = renderAdvisorReport({ version: '0.43.0.0', generated_at: 'x', findings: [], worst: null });
expect(txt).toContain('looks healthy');
});
});
+49
View File
@@ -0,0 +1,49 @@
/**
* Tests for the `advisor` MCP op gate (T7 / C3): remote callers need
* mcp.publish_advisor; local callers bypass; the op is read-only (drops
* workspace-dependent findings over MCP via runAdvisor's remote filter).
*/
import { describe, test, expect } from 'bun:test';
import { operationsByName, OperationError, type OperationContext } from '../src/core/operations.ts';
const advisor = operationsByName['advisor']!;
function ctx(over: Partial<OperationContext>, cfg: Record<string, string | null> = {}): OperationContext {
return {
engine: {
getConfig: async (k: string) => cfg[k] ?? null,
getStats: async () => { throw new Error('no'); },
getHealth: async () => { throw new Error('no'); },
executeRaw: async () => { throw new Error('no'); },
} as unknown as OperationContext['engine'],
config: {} as OperationContext['config'],
logger: { info() {}, warn() {}, error() {}, debug() {} } as unknown as OperationContext['logger'],
dryRun: false,
remote: true,
...over,
} as OperationContext;
}
describe('advisor op gate', () => {
test('op exists, is read-scoped and not localOnly (exposed over MCP)', () => {
expect(advisor).toBeDefined();
expect(advisor.scope).toBe('read');
expect(advisor.localOnly).not.toBe(true);
});
test('remote without mcp.publish_advisor → permission_denied', async () => {
await expect(advisor.handler(ctx({ remote: true }), {})).rejects.toBeInstanceOf(OperationError);
});
test('remote WITH mcp.publish_advisor → returns a report', async () => {
const report = (await advisor.handler(ctx({ remote: true }, { 'mcp.publish_advisor': 'true' }), {})) as {
findings: unknown[];
};
expect(Array.isArray(report.findings)).toBe(true);
});
test('local caller bypasses the gate', async () => {
const report = (await advisor.handler(ctx({ remote: false }), {})) as { findings: unknown[] };
expect(Array.isArray(report.findings)).toBe(true);
});
});
@@ -0,0 +1,127 @@
/**
* Tests for src/core/skillpack/brain-resident-locate.ts — the source-scoped
* discovery behind the list_brain_skillpack MCP tool and get_skill source_id.
*/
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { runInitBrainPack } from '../src/core/skillpack/init-brain-pack.ts';
import {
loadResidentPacksForServer,
getResidentSkillDetail,
deriveBrainId,
} from '../src/core/skillpack/brain-resident-locate.ts';
import type { OperationContext } from '../src/core/operations.ts';
let root: string;
let packDir: string;
let plainDir: string;
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'gbrain-brl-'));
packDir = join(root, 'pack-source');
plainDir = join(root, 'plain-source');
runInitBrainPack({ targetDir: packDir, name: 'deal-brain', firstSkillSlug: 'diligence', schemaPack: 'gbrain-base' });
// plainDir: a source with no skillpack at all
mkdtempSync(join(tmpdir(), 'noop-')); // touch tmp to avoid lints; plainDir stays empty
});
afterEach(() => {
rmSync(root, { recursive: true, force: true });
});
/** Minimal fake engine: only executeRaw (sources SELECT) + getConfig are used. */
function fakeEngine(sources: Array<{ id: string; local_path: string | null; config?: Record<string, unknown> }>, cfg: Record<string, string> = {}) {
return {
executeRaw: async () =>
sources.map((s) => ({
id: s.id,
name: s.id,
local_path: s.local_path,
last_commit: null,
last_sync_at: null,
config: s.config ?? {},
created_at: new Date(),
archived: false,
})),
getConfig: async (k: string) => cfg[k] ?? null,
} as unknown as OperationContext['engine'];
}
function ctxFor(engine: OperationContext['engine'], over: Partial<OperationContext> = {}): OperationContext {
return {
engine,
config: {} as OperationContext['config'],
logger: { info() {}, warn() {}, error() {}, debug() {} } as unknown as OperationContext['logger'],
dryRun: false,
remote: true,
...over,
} as OperationContext;
}
describe('loadResidentPacksForServer', () => {
test('finds brain_resident packs, skips plain sources', async () => {
const engine = fakeEngine([
{ id: 'default', local_path: packDir, config: { remote_url: 'https://github.com/u/deal-brain.git' } },
{ id: 'plain', local_path: plainDir },
]);
const result = await loadResidentPacksForServer(ctxFor(engine, { remote: false }));
expect(result.packs).toHaveLength(1);
const p = result.packs[0]!;
expect(p.name).toBe('deal-brain');
expect(p.source_id).toBe('default');
expect(p.skills.map((s) => s.slug)).toEqual(['diligence']);
// git remote → scaffold_spec is the git spec, NEVER a server FS path (#6)
expect(p.scaffold_spec).toBe('https://github.com/u/deal-brain.git');
expect(p.scaffold_spec).not.toContain(packDir);
});
test('computes schema_pack_match server-side against per-source config (#7)', async () => {
const engine = fakeEngine(
[{ id: 'default', local_path: packDir }],
{ 'schema_pack.source.default': 'gbrain-other' },
);
const result = await loadResidentPacksForServer(ctxFor(engine, { remote: false }));
expect(result.packs[0]!.active_schema_pack).toBe('gbrain-other');
expect(result.packs[0]!.schema_pack_match).toBe(false);
});
test('local-only source (no git remote) → scaffold_spec null', async () => {
const engine = fakeEngine([{ id: 'default', local_path: packDir }]);
const result = await loadResidentPacksForServer(ctxFor(engine, { remote: false }));
expect(result.packs[0]!.scaffold_spec).toBeNull();
});
test('source scoping: out-of-scope source is excluded', async () => {
const engine = fakeEngine([{ id: 'default', local_path: packDir }]);
// caller scoped to a different source id → no packs
const ctx = ctxFor(engine, { auth: { allowedSources: ['other'] } as OperationContext['auth'] });
const result = await loadResidentPacksForServer(ctx);
expect(result.packs).toHaveLength(0);
});
});
describe('getResidentSkillDetail', () => {
test('returns the SKILL.md body for an in-pack slug', async () => {
const engine = fakeEngine([{ id: 'default', local_path: packDir }]);
const detail = await getResidentSkillDetail(ctxFor(engine, { remote: false }), 'default', 'diligence');
expect(detail.pack_name).toBe('deal-brain');
expect(detail.slug).toBe('diligence');
expect(detail.body).toContain('# diligence');
});
test('throws not_found for an unknown slug', async () => {
const engine = fakeEngine([{ id: 'default', local_path: packDir }]);
await expect(
getResidentSkillDetail(ctxFor(engine, { remote: false }), 'default', 'nope'),
).rejects.toThrow();
});
});
describe('deriveBrainId', () => {
test('prefers git remote; falls back to path hash', () => {
expect(deriveBrainId('https://x/y.git', '/p')).toBe('git:https://x/y.git');
expect(deriveBrainId(null, '/p')).toMatch(/^path:[0-9a-f]{16}$/);
});
});