mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* 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>
131 lines
4.3 KiB
TypeScript
131 lines
4.3 KiB
TypeScript
/**
|
|
* 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);
|
|
});
|
|
});
|