From eebbfc2e3f082afa1036e81dce61eb1a9fbd1fc6 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Tue, 16 Jun 2026 16:03:36 -0700 Subject: [PATCH] feat(advisor,skillpack): list_brain_skillpack MCP tool + gbrain advisor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- src/cli.ts | 11 +- src/commands/advisor.ts | 140 ++++++++++ src/commands/connect.ts | 4 +- src/commands/serve-http.ts | 30 +++ src/core/advisor/apply.ts | 40 +++ src/core/advisor/collect-migration.ts | 33 +++ src/core/advisor/collect-schema-pack.ts | 40 +++ src/core/advisor/collect-setup-smells.ts | 72 +++++ src/core/advisor/collect-stalled-jobs.ts | 68 +++++ .../advisor/collect-uninstalled-brain-pack.ts | 72 +++++ .../advisor/collect-uninstalled-bundled.ts | 43 +++ src/core/advisor/collect-usage-shape.ts | 70 +++++ src/core/advisor/collect-version.ts | 37 +++ src/core/advisor/history.ts | 94 +++++++ src/core/advisor/recommended-set.ts | 68 +++++ src/core/advisor/render.ts | 127 +++++++++ src/core/advisor/run.ts | 91 +++++++ src/core/advisor/types.ts | 79 ++++++ src/core/config.ts | 13 + src/core/operations.ts | 87 +++++- src/core/skillpack/brain-resident-locate.ts | 253 ++++++++++++++++++ src/core/skillpack/post-install-advisory.ts | 157 ++--------- test/advisor-apply.test.ts | 51 ++++ test/advisor-core.test.ts | 170 ++++++++++++ test/advisor-op-gate.test.ts | 49 ++++ test/skillpack-brain-resident-locate.test.ts | 127 +++++++++ 26 files changed, 1885 insertions(+), 141 deletions(-) create mode 100644 src/commands/advisor.ts create mode 100644 src/core/advisor/apply.ts create mode 100644 src/core/advisor/collect-migration.ts create mode 100644 src/core/advisor/collect-schema-pack.ts create mode 100644 src/core/advisor/collect-setup-smells.ts create mode 100644 src/core/advisor/collect-stalled-jobs.ts create mode 100644 src/core/advisor/collect-uninstalled-brain-pack.ts create mode 100644 src/core/advisor/collect-uninstalled-bundled.ts create mode 100644 src/core/advisor/collect-usage-shape.ts create mode 100644 src/core/advisor/collect-version.ts create mode 100644 src/core/advisor/history.ts create mode 100644 src/core/advisor/recommended-set.ts create mode 100644 src/core/advisor/render.ts create mode 100644 src/core/advisor/run.ts create mode 100644 src/core/advisor/types.ts create mode 100644 src/core/skillpack/brain-resident-locate.ts create mode 100644 test/advisor-apply.test.ts create mode 100644 test/advisor-core.test.ts create mode 100644 test/advisor-op-gate.test.ts create mode 100644 test/skillpack-brain-resident-locate.test.ts diff --git a/src/cli.ts b/src/cli.ts index 82b7e22a6..e45374a75 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -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'); diff --git a/src/commands/advisor.ts b/src/commands/advisor.ts new file mode 100644 index 000000000..27290bbe1 --- /dev/null +++ b/src/commands/advisor.ts @@ -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 { + if (args.includes('--help') || args.includes('-h')) { + console.log( + 'gbrain advisor [--json] [--apply ]\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 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; diff --git a/src/commands/connect.ts b/src/commands/connect.ts index 5d642e7ce..0841444c5 100644 --- a/src/commands/connect.ts +++ b/src/commands/connect.ts @@ -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 ). ' + + '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 ' + diff --git a/src/commands/serve-http.ts b/src/commands/serve-http.ts index 7c8f3e32f..e7003973c 100644 --- a/src/commands/serve-http.ts +++ b/src/commands/serve-http.ts @@ -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 diff --git a/src/core/advisor/apply.ts b/src/core/advisor/apply.ts new file mode 100644 index 000000000..04c73700e --- /dev/null +++ b/src/core/advisor/apply.ts @@ -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(' ') }; +} diff --git a/src/core/advisor/collect-migration.ts b/src/core/advisor/collect-migration.ts new file mode 100644 index 000000000..3566d4d11 --- /dev/null +++ b/src/core/advisor/collect-migration.ts @@ -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, + }, + ]; + }, +}; diff --git a/src/core/advisor/collect-schema-pack.ts b/src/core/advisor/collect-schema-pack.ts new file mode 100644 index 000000000..b25eb676d --- /dev/null +++ b/src/core/advisor/collect-schema-pack.ts @@ -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, + }, + ]; + } + }, +}; diff --git a/src/core/advisor/collect-setup-smells.ts b/src/core/advisor/collect-setup-smells.ts new file mode 100644 index 000000000..de7884e24 --- /dev/null +++ b/src/core/advisor/collect-setup-smells.ts @@ -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 } }, key: string): Promise { + 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', ''] }, + 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', ''] }, + 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; + }, +}; diff --git a/src/core/advisor/collect-stalled-jobs.ts b/src/core/advisor/collect-stalled-jobs.ts new file mode 100644 index 000000000..940619d6a --- /dev/null +++ b/src/core/advisor/collect-stalled-jobs.ts @@ -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; + }, +}; diff --git a/src/core/advisor/collect-uninstalled-brain-pack.ts b/src/core/advisor/collect-uninstalled-brain-pack.ts new file mode 100644 index 000000000..a9f12f2f4 --- /dev/null +++ b/src/core/advisor/collect-uninstalled-brain-pack.ts @@ -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)?.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; + }, +}; diff --git a/src/core/advisor/collect-uninstalled-bundled.ts b/src/core/advisor/collect-uninstalled-bundled.ts new file mode 100644 index 000000000..f50359d8c --- /dev/null +++ b/src/core/advisor/collect-uninstalled-bundled.ts @@ -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; + 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; + }, +}; diff --git a/src/core/advisor/collect-usage-shape.ts b/src/core/advisor/collect-usage-shape.ts new file mode 100644 index 000000000..b54dbfbd3 --- /dev/null +++ b/src/core/advisor/collect-usage-shape.ts @@ -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; + }, +}; diff --git a/src/core/advisor/collect-version.ts b/src/core/advisor/collect-version.ts new file mode 100644 index 000000000..968481f51 --- /dev/null +++ b/src/core/advisor/collect-version.ts @@ -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, + }, + ]; + }, +}; diff --git a/src/core/advisor/history.ts b/src/core/advisor/history.ts new file mode 100644 index 000000000..073238337 --- /dev/null +++ b/src/core/advisor/history.ts @@ -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(', ')})`; +} diff --git a/src/core/advisor/recommended-set.ts b/src/core/advisor/recommended-set.ts new file mode 100644 index 000000000..57c4b1a06 --- /dev/null +++ b/src/core/advisor/recommended-set.ts @@ -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` 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; +} diff --git a/src/core/advisor/render.ts b/src/core/advisor/render.ts new file mode 100644 index 000000000..95a0776f5 --- /dev/null +++ b/src/core/advisor/render.ts @@ -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 = { + 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 # 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; +} diff --git a/src/core/advisor/run.ts b/src/core/advisor/run.ts new file mode 100644 index 000000000..d6291e2d0 --- /dev/null +++ b/src/core/advisor/run.ts @@ -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 = { 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 { + 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, + }; +} diff --git a/src/core/advisor/types.ts b/src/core/advisor/types.ts new file mode 100644 index 000000000..2ea470a74 --- /dev/null +++ b/src/core/advisor/types.ts @@ -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 `. 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; +} + +export interface AdvisorReport { + version: string; + generated_at: string; + findings: AdvisorFinding[]; + /** Highest severity present, for exit-code mapping (E2). */ + worst: AdvisorSeverity | null; +} diff --git a/src/core/config.ts b/src/core/config.ts index 70ddca5b7..e4260f498 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -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', diff --git a/src/core/operations.ts b/src/core/operations.ts index aa6f41e41..2145624b0 100644 --- a/src/core/operations.ts +++ b/src/core/operations.ts @@ -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 ).', + 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 diff --git a/src/core/skillpack/brain-resident-locate.ts b/src/core/skillpack/brain-resident-locate.ts new file mode 100644 index 000000000..b34f2b423 --- /dev/null +++ b/src/core/skillpack/brain-resident-locate.ts @@ -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 ` 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 { + 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 { + const scope = sourceScopeOpts(ctx); + const allowed: Set | 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)?.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 { + 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, + }; +} diff --git a/src/core/skillpack/post-install-advisory.ts b/src/core/skillpack/post-install-advisory.ts index 2b34dcc13..dbf7936ab 100644 --- a/src/core/skillpack/post-install-advisory.ts +++ b/src/core/skillpack/post-install-advisory.ts @@ -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 # 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. diff --git a/test/advisor-apply.test.ts b/test/advisor-apply.test.ts new file mode 100644 index 000000000..a5af623fc --- /dev/null +++ b/test/advisor-apply.test.ts @@ -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 { + 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); + }); +}); diff --git a/test/advisor-core.test.ts b/test/advisor-core.test.ts new file mode 100644 index 000000000..abf2b9384 --- /dev/null +++ b/test/advisor-core.test.ts @@ -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 { + return { + id: 'x', + severity: 'info', + title: 't', + fix: { command_argv: null }, + collector: 'usage-shape', + ask_user: true, + ...over, + }; +} + +function ctx(engine: Partial, over: Partial = {}): 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'); + }); +}); diff --git a/test/advisor-op-gate.test.ts b/test/advisor-op-gate.test.ts new file mode 100644 index 000000000..027e77e40 --- /dev/null +++ b/test/advisor-op-gate.test.ts @@ -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, cfg: Record = {}): 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); + }); +}); diff --git a/test/skillpack-brain-resident-locate.test.ts b/test/skillpack-brain-resident-locate.test.ts new file mode 100644 index 000000000..d9ac3c5ba --- /dev/null +++ b/test/skillpack-brain-resident-locate.test.ts @@ -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 }>, cfg: Record = {}) { + 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 { + 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}$/); + }); +});