mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
v0.42.47.0 feat(skillpack,advisor): brain-resident skillpacks + proactive gbrain advisor (#2180) (#2231)
* feat(skillpack): brain_resident manifest fields + init-brain-pack scaffolder + tools version-skew lint Add optional brain_resident/schema_pack to the v1 manifest (additive, forward-compatible). New runInitBrainPack scaffolds a brain-resident pack (brain_resident:true, exact gbrain_min_version, 5-section machine-parseable README) beside brain content; applyWritePlan factored out of init-scaffold. brain-pack-lint validates each skill's declared tools: against the serving op set (E6 version-skew). Wires gbrain skillpack init-brain-pack. * feat(skillpack): Topology A brain-pack discovery on sources add + bounded nag After 'gbrain sources add', if the source ships a brain_resident pack, print an agent-readable advisory (ask the user before scaffolding). nag-state.ts tracks declines per (source-repo brain_id, source, pack) with escalate-then-suppress; declines count only on CLI-interactive displays, never cron/MCP. Fail-open: a malformed/absent pack never breaks sources add. * 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). * feat(skills): bundle gbrain-advisor skill + weekly cron recipe + ranking eval skills/gbrain-advisor teaches a harness to run gbrain advisor on a cadence and ping the user (read-only; ask before fixing). Registered in manifest.json, RESOLVER.md, openclaw.plugin.json. E4 ranking-precision eval on seeded-defect fixtures (100%). * chore: bump version and changelog (v0.42.47.0) Brain-resident skillpacks + gbrain advisor (#2180). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: sync CLAUDE.md + KEY_FILES for brain-resident skillpacks + advisor (#2180) Skill count 29->30, Skills section gains the brain-resident skillpacks + advisor capability, KEY_FILES gets current-state entries for the new modules. Regenerate llms bundles. * test,fix: align stale assertions with generalized advisory + advisor resolver triggers (#2180) - post-install-advisory.test.ts: install→scaffold wording (the install verb was removed); restore two-column in book-mirror copy; drop the removed skillpack-list line. - RESOLVER.md: gbrain-advisor trigger now fuzzy-matches a declared frontmatter trigger (resolver round-trip D5/C). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: regenerate llms bundle for updated advisor resolver row (#2180) --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
c023a6041d
commit
9d88680a51
@@ -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,131 @@
|
||||
/**
|
||||
* E4 — advisor ranking-precision eval.
|
||||
*
|
||||
* Measures the FEATURE (per the North Star): on synthetic, anonymized brains
|
||||
* with KNOWN seeded defects, does the advisor surface the right findings and
|
||||
* rank them correctly (critical > warn > info)? This tests the advisor's own
|
||||
* logic, not the remediation loop — each fixture injects defects through the
|
||||
* exact channels the collectors read (getStats/getHealth, minion_jobs, config),
|
||||
* then we score precision = (expected findings surfaced) / (expected total).
|
||||
*
|
||||
* Runs in the unit shard (under test/) so it's CI-gated; deterministic and
|
||||
* zero-API-cost (seeded fake engines), so it belongs with the unit suite rather
|
||||
* than a manual harness. Never ships downstream (test/ is not bundled).
|
||||
*
|
||||
* Synthetic only: no real brain content (anonymized fixtures), per the
|
||||
* calibration-corpus rule.
|
||||
*/
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { runAdvisor } from '../src/core/advisor/run.ts';
|
||||
import type { AdvisorContext } from '../src/core/advisor/types.ts';
|
||||
|
||||
interface Fixture {
|
||||
name: string;
|
||||
engine: Partial<AdvisorContext['engine']>;
|
||||
config?: Partial<AdvisorContext['config']>;
|
||||
remote?: boolean;
|
||||
/** Finding ids (or id prefixes) we expect to surface. */
|
||||
expect: string[];
|
||||
/** Finding ids that must NOT surface. */
|
||||
forbid?: string[];
|
||||
}
|
||||
|
||||
const HEALTHY_STATS = {
|
||||
getStats: async () => ({
|
||||
page_count: 500, chunk_count: 0, embedded_count: 0, link_count: 0, tag_count: 0, timeline_entry_count: 0, pages_by_type: {},
|
||||
}),
|
||||
getHealth: async () => ({
|
||||
page_count: 500, embed_coverage: 0.99, stale_pages: 0, orphan_pages: 0, missing_embeddings: 0,
|
||||
brain_score: 95, dead_links: 0, link_coverage: 1, timeline_coverage: 1, most_connected: [],
|
||||
embed_coverage_score: 35, link_density_score: 25, timeline_coverage_score: 15, no_orphans_score: 15, no_dead_links_score: 10,
|
||||
}),
|
||||
getConfig: async () => null,
|
||||
executeRaw: async () => [],
|
||||
};
|
||||
|
||||
const FIXTURES: Fixture[] = [
|
||||
{
|
||||
name: 'healthy brain → no findings',
|
||||
engine: { ...HEALTHY_STATS },
|
||||
expect: [],
|
||||
forbid: ['low_embed_coverage', 'orphan_pages', 'embeddings_disabled'],
|
||||
},
|
||||
{
|
||||
name: 'degraded recall: low embed coverage + orphans',
|
||||
engine: {
|
||||
...HEALTHY_STATS,
|
||||
getHealth: async () => ({
|
||||
page_count: 500, embed_coverage: 0.3, stale_pages: 0, orphan_pages: 12, missing_embeddings: 350,
|
||||
brain_score: 40, dead_links: 2, link_coverage: 0.2, timeline_coverage: 0.2, most_connected: [],
|
||||
embed_coverage_score: 10, link_density_score: 5, timeline_coverage_score: 3, no_orphans_score: 2, no_dead_links_score: 8,
|
||||
}),
|
||||
},
|
||||
expect: ['low_embed_coverage', 'orphan_pages', 'dead_links'],
|
||||
},
|
||||
{
|
||||
name: 'stalled worker + stale source',
|
||||
engine: {
|
||||
...HEALTHY_STATS,
|
||||
executeRaw: (async (sql: string) => {
|
||||
if (/minion_jobs/.test(sql)) return [{ name: 'embed-backfill', n: 3 }];
|
||||
if (/sources/.test(sql)) return [{ id: 'wiki' }];
|
||||
return [];
|
||||
}) as AdvisorContext['engine']['executeRaw'],
|
||||
},
|
||||
expect: ['stalled_job:embed-backfill', 'stale_sync:wiki'],
|
||||
},
|
||||
{
|
||||
name: 'setup smell: embeddings disabled',
|
||||
engine: { ...HEALTHY_STATS },
|
||||
config: { embedding_disabled: true } as AdvisorContext['config'],
|
||||
expect: ['embeddings_disabled'],
|
||||
},
|
||||
];
|
||||
|
||||
function ctxFor(fx: Fixture): AdvisorContext {
|
||||
return {
|
||||
engine: fx.engine as AdvisorContext['engine'],
|
||||
config: (fx.config ?? {}) as AdvisorContext['config'],
|
||||
version: '0.43.0.0',
|
||||
workspace: null,
|
||||
skillsDir: null,
|
||||
now: new Date('2026-06-16T00:00:00Z'),
|
||||
remote: fx.remote ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
describe('E4 advisor ranking-precision eval', () => {
|
||||
test('every seeded defect is surfaced and severities rank correctly', async () => {
|
||||
let expectedTotal = 0;
|
||||
let surfaced = 0;
|
||||
const SEV: Record<string, number> = { critical: 0, warn: 1, info: 2 };
|
||||
|
||||
for (const fx of FIXTURES) {
|
||||
const report = await runAdvisor(ctxFor(fx));
|
||||
const ids = report.findings.map((f) => f.id);
|
||||
|
||||
for (const want of fx.expect) {
|
||||
expectedTotal++;
|
||||
if (ids.some((id) => id === want || id.startsWith(want))) surfaced++;
|
||||
else console.error(` [${fx.name}] MISSED expected finding: ${want}`);
|
||||
}
|
||||
for (const no of fx.forbid ?? []) {
|
||||
expect(ids.find((id) => id === no || id.startsWith(no))).toBeUndefined();
|
||||
}
|
||||
// Ranking monotonic: severities never increase down the list.
|
||||
const sevs = report.findings.map((f) => SEV[f.severity]!);
|
||||
for (let i = 1; i < sevs.length; i++) expect(sevs[i]!).toBeGreaterThanOrEqual(sevs[i - 1]!);
|
||||
}
|
||||
|
||||
const precision = expectedTotal === 0 ? 1 : surfaced / expectedTotal;
|
||||
console.error(`\nE4 advisor ranking precision: ${surfaced}/${expectedTotal} = ${(precision * 100).toFixed(0)}%`);
|
||||
expect(precision).toBe(1);
|
||||
});
|
||||
|
||||
test('remote advisor drops workspace-dependent findings (A1)', async () => {
|
||||
// A brain-pack uninstalled finding is workspace_dependent; over MCP it must
|
||||
// not appear even though the collector would fire locally.
|
||||
const report = await runAdvisor(ctxFor({ ...FIXTURES[0]!, remote: true }));
|
||||
expect(report.findings.every((f) => !f.workspace_dependent)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -113,7 +113,7 @@ describe('buildAdvisory — partial-install path', () => {
|
||||
expect(advisory).toContain('book-mirror');
|
||||
expect(advisory).not.toContain('article-enrichment');
|
||||
expect(advisory).not.toContain('strategic-reading');
|
||||
expect(advisory).toContain('gbrain skillpack install book-mirror');
|
||||
expect(advisory).toContain('gbrain skillpack scaffold book-mirror');
|
||||
});
|
||||
|
||||
it('uses --all command when ALL recommended are missing (fresh workspace)', () => {
|
||||
@@ -125,7 +125,7 @@ describe('buildAdvisory — partial-install path', () => {
|
||||
targetSkillsDir: skillsDir,
|
||||
});
|
||||
expect(advisory).not.toBeNull();
|
||||
expect(advisory).toContain('gbrain skillpack install --all');
|
||||
expect(advisory).toContain('gbrain skillpack scaffold --all');
|
||||
expect(advisory).toContain('book-mirror');
|
||||
expect(advisory).toContain('article-enrichment');
|
||||
expect(advisory).toContain('strategic-reading');
|
||||
@@ -167,7 +167,7 @@ describe('buildAdvisory — agent-readable framing', () => {
|
||||
})!;
|
||||
expect(advisory).toContain('ACTION FOR THE AGENT');
|
||||
expect(advisory).toContain('Ask the user');
|
||||
expect(advisory).toContain('Do NOT install without asking');
|
||||
expect(advisory).toContain('Do NOT scaffold without asking');
|
||||
expect(advisory).toContain('user owns this decision');
|
||||
});
|
||||
|
||||
@@ -213,8 +213,8 @@ describe('buildAdvisory — agent-readable framing', () => {
|
||||
targetWorkspace: workspace,
|
||||
targetSkillsDir: skillsDir,
|
||||
})!;
|
||||
expect(advisory).toContain('gbrain skillpack install --all');
|
||||
expect(advisory).toContain('gbrain skillpack list');
|
||||
expect(advisory).toContain('gbrain skillpack scaffold --all');
|
||||
expect(advisory).toContain('ACTION FOR THE AGENT');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -228,6 +228,6 @@ describe('buildAdvisory — no workspace detected', () => {
|
||||
});
|
||||
expect(advisory).not.toBeNull();
|
||||
// No workspace -> empty installed set -> all recommended treated as missing.
|
||||
expect(advisory).toContain('gbrain skillpack install --all');
|
||||
expect(advisory).toContain('gbrain skillpack scaffold --all');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}$/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Tests for src/core/skillpack/init-brain-pack.ts (the brain-resident pack
|
||||
* scaffolder) and src/core/skillpack/brain-pack-lint.ts (E6 version-skew lint).
|
||||
*/
|
||||
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { mkdtempSync, rmSync, readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
import {
|
||||
runInitBrainPack,
|
||||
InitBrainPackError,
|
||||
} from '../src/core/skillpack/init-brain-pack.ts';
|
||||
import { validateSkillpackManifest } from '../src/core/skillpack/manifest-v1.ts';
|
||||
import { lintBrainPackTools } from '../src/core/skillpack/brain-pack-lint.ts';
|
||||
import { VERSION } from '../src/version.ts';
|
||||
|
||||
let dir: string;
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'gbrain-brainpack-'));
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('runInitBrainPack', () => {
|
||||
test('writes a brain_resident manifest pinned to exact VERSION', () => {
|
||||
const result = runInitBrainPack({ targetDir: dir, name: 'deal-brain', schemaPack: 'gbrain-base' });
|
||||
expect(result.manifest.brain_resident).toBe(true);
|
||||
expect(result.manifest.schema_pack).toBe('gbrain-base');
|
||||
expect(result.manifest.gbrain_min_version).toBe(VERSION); // exact, not major.minor.0
|
||||
// round-trips through the validator
|
||||
const raw = JSON.parse(readFileSync(join(dir, 'skillpack.json'), 'utf-8'));
|
||||
expect(() => validateSkillpackManifest(raw)).not.toThrow();
|
||||
});
|
||||
|
||||
test('README has all 5 stable machine-parseable headings', () => {
|
||||
runInitBrainPack({ targetDir: dir, name: 'deal-brain' });
|
||||
const readme = readFileSync(join(dir, 'README.md'), 'utf-8');
|
||||
expect(readme).toContain('## 1. What this brain is');
|
||||
expect(readme).toContain('## 2. Skills in this pack');
|
||||
expect(readme).toContain('## 3. Install');
|
||||
expect(readme).toContain('## 4. Conventions this brain expects');
|
||||
expect(readme).toContain('## 5. Version compatibility');
|
||||
});
|
||||
|
||||
test('refuses to overwrite existing files', () => {
|
||||
writeFileSync(join(dir, 'skillpack.json'), '{"keep":"me"}');
|
||||
const result = runInitBrainPack({ targetDir: dir, name: 'deal-brain' });
|
||||
expect(result.filesSkippedExisting).toContain(join(dir, 'skillpack.json'));
|
||||
expect(readFileSync(join(dir, 'skillpack.json'), 'utf-8')).toBe('{"keep":"me"}');
|
||||
});
|
||||
|
||||
test('dry-run writes nothing', () => {
|
||||
const result = runInitBrainPack({ targetDir: dir, name: 'deal-brain', dryRun: true });
|
||||
expect(result.filesWritten.length).toBeGreaterThan(0);
|
||||
expect(existsSync(join(dir, 'skillpack.json'))).toBe(false);
|
||||
});
|
||||
|
||||
test('rejects non-kebab name', () => {
|
||||
expect(() => runInitBrainPack({ targetDir: dir, name: 'Deal Brain' })).toThrow(InitBrainPackError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('lintBrainPackTools (E6)', () => {
|
||||
test('flags a declared tool the serving op set does not have', () => {
|
||||
runInitBrainPack({ targetDir: dir, name: 'deal-brain', firstSkillSlug: 'diligence' });
|
||||
// Inject a tools: frontmatter with one known + one unknown op.
|
||||
const skillMd = join(dir, 'skills/diligence/SKILL.md');
|
||||
writeFileSync(
|
||||
skillMd,
|
||||
['---', 'name: diligence', 'description: x', 'tools: [search, totally_made_up_op]', '---', '', '# diligence', ''].join('\n'),
|
||||
);
|
||||
const result = lintBrainPackTools(dir, new Set(['search', 'put_page']));
|
||||
expect(result.unknownTools).toEqual([{ skill: 'skills/diligence', tool: 'totally_made_up_op' }]);
|
||||
});
|
||||
|
||||
test('no findings when all tools known (fresh pack has no tools:)', () => {
|
||||
runInitBrainPack({ targetDir: dir, name: 'deal-brain', firstSkillSlug: 'diligence' });
|
||||
const result = lintBrainPackTools(dir, new Set(['search', 'put_page']));
|
||||
expect(result.unknownTools).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -390,3 +390,51 @@ describe('bundleManifestFromSkillpack — adapter', () => {
|
||||
expect(bundle.shared_deps).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('brain-resident fields (issue #2180)', () => {
|
||||
test('accepts brain_resident:true + schema_pack', () => {
|
||||
const result = validateSkillpackManifest({
|
||||
...VALID_MANIFEST,
|
||||
brain_resident: true,
|
||||
schema_pack: 'gbrain-base',
|
||||
});
|
||||
expect(result.brain_resident).toBe(true);
|
||||
expect(result.schema_pack).toBe('gbrain-base');
|
||||
});
|
||||
|
||||
test('both absent still valid (backward compatible)', () => {
|
||||
const result = validateSkillpackManifest(VALID_MANIFEST);
|
||||
expect(result.brain_resident).toBeUndefined();
|
||||
expect(result.schema_pack).toBeUndefined();
|
||||
});
|
||||
|
||||
test('rejects non-boolean brain_resident', () => {
|
||||
try {
|
||||
validateSkillpackManifest({ ...VALID_MANIFEST, brain_resident: 'yes' });
|
||||
throw new Error('should have thrown');
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(SkillpackManifestError);
|
||||
expect((err as SkillpackManifestError).code).toBe('manifest_invalid_field');
|
||||
expect((err as SkillpackManifestError).detail?.field).toBe('brain_resident');
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects non-kebab schema_pack', () => {
|
||||
try {
|
||||
validateSkillpackManifest({ ...VALID_MANIFEST, schema_pack: 'Not Kebab' });
|
||||
throw new Error('should have thrown');
|
||||
} catch (err) {
|
||||
expect((err as SkillpackManifestError).code).toBe('manifest_invalid_field');
|
||||
expect((err as SkillpackManifestError).detail?.field).toBe('schema_pack');
|
||||
}
|
||||
});
|
||||
|
||||
test('forward-compat: unknown extra fields are tolerated', () => {
|
||||
const result = validateSkillpackManifest({
|
||||
...VALID_MANIFEST,
|
||||
brain_resident: true,
|
||||
some_future_field: { nested: 1 },
|
||||
});
|
||||
expect(result.brain_resident).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Tests for src/core/skillpack/nag-state.ts — the brain-pack install nag policy.
|
||||
*/
|
||||
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { mkdtempSync, rmSync, existsSync, writeFileSync, readFileSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
import {
|
||||
loadNagState,
|
||||
saveNagState,
|
||||
findNag,
|
||||
upsertNag,
|
||||
decideNagAction,
|
||||
recordNagDisplay,
|
||||
DEFAULT_NAG_CEILING,
|
||||
type NagEntry,
|
||||
} from '../src/core/skillpack/nag-state.ts';
|
||||
|
||||
let dir: string;
|
||||
let statePath: string;
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'gbrain-nag-'));
|
||||
statePath = join(dir, 'skillpack-nag-state.json');
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const KEY = { brain_id: 'git:repo', source_id: 'host', pack_name: 'deal-brain' };
|
||||
|
||||
function entry(over: Partial<NagEntry> = {}): NagEntry {
|
||||
return {
|
||||
...KEY,
|
||||
pack_version: '0.1.0',
|
||||
prompted_at: '2026-06-16T00:00:00Z',
|
||||
declined_count: 1,
|
||||
suppressed: false,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe('atomic load/save', () => {
|
||||
test('round-trips and starts empty on missing file', () => {
|
||||
expect(loadNagState({ statePath }).entries).toEqual([]);
|
||||
saveNagState(upsertNag(loadNagState({ statePath }), entry()), { statePath });
|
||||
expect(existsSync(statePath)).toBe(true);
|
||||
expect(loadNagState({ statePath }).entries).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('fail-open on corrupt JSON (returns empty, never throws)', () => {
|
||||
writeFileSync(statePath, '{ not json');
|
||||
expect(loadNagState({ statePath }).entries).toEqual([]);
|
||||
});
|
||||
|
||||
test('fail-open on unknown schema version', () => {
|
||||
writeFileSync(statePath, JSON.stringify({ schema_version: 'future-v9', entries: [entry()] }));
|
||||
expect(loadNagState({ statePath }).entries).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findNag / upsertNag keyed by (brain_id, source_id, pack_name)', () => {
|
||||
test('upsert replaces the matching triple only', () => {
|
||||
let s = upsertNag(loadNagState({ statePath }), entry({ declined_count: 1 }));
|
||||
s = upsertNag(s, entry({ declined_count: 2 }));
|
||||
expect(s.entries).toHaveLength(1);
|
||||
expect(findNag(s, KEY)?.declined_count).toBe(2);
|
||||
s = upsertNag(s, entry({ source_id: 'other', declined_count: 5 }));
|
||||
expect(s.entries).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('decideNagAction policy matrix', () => {
|
||||
test('no entry → full/first', () => {
|
||||
expect(decideNagAction(undefined, { pack_version: '0.1.0' })).toEqual({
|
||||
show: true,
|
||||
level: 'full',
|
||||
reason: 'first',
|
||||
});
|
||||
});
|
||||
|
||||
test('--no-skill-nag → hidden', () => {
|
||||
expect(decideNagAction(entry(), { pack_version: '0.1.0', noNagFlag: true }).show).toBe(false);
|
||||
});
|
||||
|
||||
test('same version, under ceiling → short reminder', () => {
|
||||
expect(decideNagAction(entry({ declined_count: 1 }), { pack_version: '0.1.0' })).toEqual({
|
||||
show: true,
|
||||
level: 'short',
|
||||
reason: 'reminder',
|
||||
});
|
||||
});
|
||||
|
||||
test('ceiling reached → hidden', () => {
|
||||
expect(
|
||||
decideNagAction(entry({ declined_count: DEFAULT_NAG_CEILING }), { pack_version: '0.1.0' }).show,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('suppressed flag → hidden', () => {
|
||||
expect(decideNagAction(entry({ suppressed: true }), { pack_version: '0.1.0' }).show).toBe(false);
|
||||
});
|
||||
|
||||
test('version bump while uninstalled → full/version_bump even past ceiling', () => {
|
||||
expect(
|
||||
decideNagAction(entry({ declined_count: 9, suppressed: true }), { pack_version: '0.2.0' }),
|
||||
).toEqual({ show: true, level: 'full', reason: 'version_bump' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('recordNagDisplay', () => {
|
||||
test('increments on same version and suppresses at ceiling', () => {
|
||||
const e1 = recordNagDisplay(undefined, KEY, { pack_version: '0.1.0', nowIso: 'now' });
|
||||
expect(e1.declined_count).toBe(1);
|
||||
const e2 = recordNagDisplay(e1, KEY, { pack_version: '0.1.0', nowIso: 'now' });
|
||||
const e3 = recordNagDisplay(e2, KEY, { pack_version: '0.1.0', nowIso: 'now' });
|
||||
expect(e3.declined_count).toBe(DEFAULT_NAG_CEILING);
|
||||
expect(e3.suppressed).toBe(true);
|
||||
});
|
||||
|
||||
test('version bump resets count to 1 and clears suppression', () => {
|
||||
const bumped = recordNagDisplay(
|
||||
entry({ declined_count: 5, suppressed: true }),
|
||||
KEY,
|
||||
{ pack_version: '0.2.0', nowIso: 'now' },
|
||||
);
|
||||
expect(bumped.declined_count).toBe(1);
|
||||
expect(bumped.suppressed).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user