mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-28 06:23:01 +00:00
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:
+10
-1
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
@@ -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 ' +
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(' ') };
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -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(', ')})`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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}$/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user