mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-29 19:01:39 +00:00
* feat: v0.19.0 — skillify loop + AGENTS.md compat + brain-first convention This is the v0.19.0 release. The branch ships four new CLI commands, a refactor to check-resolvable, and an expansion of the brain-first convention for sub-agent tool discovery. The original commit message described only the convention expansion, undercounting the scope by ~5x; this amend captures the full release. NEW COMMANDS - gbrain skillify scaffold <name> — 4 stub files + idempotent resolver row - gbrain skillify check [path] — 10-item post-task audit (promoted) - gbrain skillpack list / install — curated 25-skill bundle, atomic install - gbrain skillpack diff <name> — per-file diff preview - gbrain routing-eval — dedicated CI verb for Check 5 fixtures CHECK-RESOLVABLE REFACTOR - Accepts AGENTS.md as a resolver file alongside RESOLVER.md, at either the skills directory or one level up (workspace root layout). - Auto-derives the skill manifest by walking skills/*/SKILL.md when manifest.json is missing. - Splits ResolvableReport into errors[] + warnings[] so advisory checks (filing audit, routing gaps, DRY violations) don't break CI by default. - New --strict opt-in flag promotes warnings to exit 1. BRAIN-FIRST CONVENTION - skills/conventions/brain-first.md expanded from 5-step lookup guide to full sub-agent reference: tool inventory, lookup chain, score thresholds, authority hierarchy, sync rules, entity page conventions, sub-agent propagation rule. PRODUCTION-READINESS HARDENING (this branch's review pass) - routing-eval --llm: emits stderr placeholder notice + runs structural layer only. README, CHANGELOG, CLI help all rewritten consistently. Was a silent no-op against documented contract. - skillpack installer: receipt comment in fence (cumulative-slugs="...") preserves single-skill-install accumulation while letting install --all prune removed bundle skills cleanly. Unknown rows preserved + stderr warning for the operating agent. Pre-v0.19 fences upgrade silently. - skillify scaffold: resolver-row regex broadened to detect backticked, quoted, and bare path forms. No duplicate row on --force after the user normalizes formatting. - scripts/check-privacy.sh: now wired into package.json test chain so the wintermute-ban rule is actually enforced. New regression test. - E2E Tier 2 (LLM skills) promoted from schedule-only to required per-PR CI. Local Tier 1 + Tier 2 verified clean. - Stale v0.17/v0.18 version labels rewritten across new files. TESTS - test/routing-eval-cli.test.ts: 4 cases covering --llm warn semantics - test/privacy-script-wired.test.ts: regression guard for CI wiring - test/skillpack-install.test.ts: 4 new cases for receipt + cumulative + unknown-row preserve+warn + pre-v0.19 upgrade path - test/skillify-scaffold.test.ts: 4 new cases for broadened regex VERIFICATION - bun test: 2237 pass / 18 known PGLite-contention flakes (CI green; documented as P3 dev-experience in TODOS.md) - bun run typecheck: clean - bun run test:e2e: 18/19 files green (1 pre-existing flake on master, not caused by this branch — verified via git stash) - llms.txt + llms-full.txt regenerated to match README + CHANGELOG Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: scrub banned fork name from public artifacts The privacy guard wired into the test chain in this branch caught 5 pre-existing references to the banned OpenClaw fork name in CHANGELOG.md (2x), skills/migrations/v0.19.0.md (1x), src/cli.ts (1x), and src/commands/sync.ts (1x). All originated in master's v0.19.0 release notes and migration doc when the privacy script existed but wasn't wired into CI yet. Replacements per CLAUDE.md privacy mapping: - Origin-story copy (CHANGELOG layer narratives, code comments naming the production deployment that drove the feature) → "Garry's OpenClaw" - Reader-facing migration step → "your OpenClaw" No code semantics changed. Comments + headings only. Verification: scripts/check-privacy.sh exits 0, full CI guard chain green (privacy + jsonb + progress + wasm + typecheck). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: bump VERSION to 0.24.0 + new CHANGELOG entry Bump branch version above master's v0.21.0 per CLAUDE.md "CHANGELOG + VERSION are branch-scoped" rule. The new v0.24.0 entry at the top of CHANGELOG covers what THIS branch adds vs master: - routing-eval --llm honesty pass (4-surface contract drift fix) - skillpack installer cumulative-receipt + unknown-row preserve+warn (the Codex-caught regression that would have shipped in master if the original v0.19.0 had landed without this branch's review pass) - skillify scaffold resolver-row regex broadening (backtick + quoted + bare forms; idempotency contract preserved under hand-editing) - 5 banned-name leaks scrubbed from public artifacts - check-privacy.sh wired into CI test chain + regression guard test - 7 stale v0.17/v0.18 version labels rewritten across 5 files - Tier 2 (LLM-skills E2E) promoted from schedule-only to required per-PR VERSION 0.21.0 → 0.24.0 package.json version field synced. llms.txt + llms-full.txt regenerated (no content drift; sizes match). Test suite: 62/62 green across the 5 test files this branch added or extended (routing-eval-cli, privacy-script-wired, skillpack-install, skillify-scaffold, build-llms). CI guards: privacy + jsonb + progress + wasm + typecheck all clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: update project documentation for v0.24.0 Auto-discovered drift via /document-release after the v0.24.0 hardening pass landed. All factual corrections clearly warranted by the diff. CLAUDE.md: - Skillpack installer: documented the cumulative-slugs receipt comment, install --all prune semantics, unknown-row preserve+warn behavior, and pre-v0.24 silent upgrade. Was previously vague about "tracks a skill manifest so install --update diffs cleanly" without explaining what the receipt is or why it matters. - routing-eval: replaced the false claim that --llm "opts into a Haiku tie-break layer for CI." Now correctly describes the placeholder semantic landed in v0.24.0 (stderr notice + structural-only run). README.md: - Skillpack section: added one paragraph on the receipt comment + the user-visible stderr message for hand-added rows. Connects the safe rerun promise to the v0.24.0 implementation that actually enforces it. CONTRIBUTING.md: - Running tests section: now recommends `bun run test` (full CI guard chain + typecheck + tests) before pushing. Names each guard so new contributors understand what catches what. The privacy guard (newly wired in v0.24.0) is one of these — without `bun run test` you'd skip it locally and find out from CI. llms-full.txt: regenerated to reflect CLAUDE.md changes. Verification: full guard chain green locally (privacy + jsonb + progress + wasm + typecheck). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Garry Tan <garry@ycombinator.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
421 lines
13 KiB
TypeScript
421 lines
13 KiB
TypeScript
/**
|
|
* Tests for src/core/skillify/generator.ts (W4).
|
|
* Mechanical scaffold plan + apply, idempotency (D-CX-7), stub sentinel.
|
|
*/
|
|
|
|
import { describe, expect, it, afterEach } from 'bun:test';
|
|
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs';
|
|
import { join } from 'path';
|
|
import { tmpdir } from 'os';
|
|
|
|
import {
|
|
applyScaffold,
|
|
planScaffold,
|
|
SkillifyScaffoldError,
|
|
SKILL_NAME_PATTERN,
|
|
} from '../src/core/skillify/generator.ts';
|
|
import { SKILLIFY_STUB_MARKER } from '../src/core/skillify/templates.ts';
|
|
|
|
const created: string[] = [];
|
|
|
|
function scratchRepo(): { root: string; skillsDir: string } {
|
|
const root = mkdtempSync(join(tmpdir(), 'skillify-repo-'));
|
|
created.push(root);
|
|
const skillsDir = join(root, 'skills');
|
|
mkdirSync(skillsDir, { recursive: true });
|
|
mkdirSync(join(root, 'test'), { recursive: true });
|
|
writeFileSync(
|
|
join(skillsDir, 'RESOLVER.md'),
|
|
'# RESOLVER\n\n## Brain operations\n\n| Trigger | Skill |\n|---------|-------|\n| "existing thing" | `skills/existing/SKILL.md` |\n',
|
|
);
|
|
return { root, skillsDir };
|
|
}
|
|
|
|
afterEach(() => {
|
|
while (created.length) {
|
|
const d = created.pop();
|
|
if (d && existsSync(d)) rmSync(d, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
describe('SKILL_NAME_PATTERN', () => {
|
|
it('accepts valid kebab-case', () => {
|
|
expect(SKILL_NAME_PATTERN.test('context-now')).toBe(true);
|
|
expect(SKILL_NAME_PATTERN.test('a')).toBe(true);
|
|
expect(SKILL_NAME_PATTERN.test('calendar-recall-v2')).toBe(true);
|
|
});
|
|
it('rejects uppercase, spaces, underscores, leading digits', () => {
|
|
expect(SKILL_NAME_PATTERN.test('ContextNow')).toBe(false);
|
|
expect(SKILL_NAME_PATTERN.test('context now')).toBe(false);
|
|
expect(SKILL_NAME_PATTERN.test('context_now')).toBe(false);
|
|
expect(SKILL_NAME_PATTERN.test('2-skill')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('planScaffold', () => {
|
|
it('throws SkillifyScaffoldError on invalid name', () => {
|
|
const { root, skillsDir } = scratchRepo();
|
|
expect(() =>
|
|
planScaffold({
|
|
skillsDir,
|
|
repoRoot: root,
|
|
vars: {
|
|
name: 'Bad Name',
|
|
description: 'x',
|
|
triggers: [],
|
|
writesTo: [],
|
|
writesPages: false,
|
|
mutating: false,
|
|
},
|
|
}),
|
|
).toThrow(SkillifyScaffoldError);
|
|
});
|
|
|
|
it('plans 4 files + resolver append for a new skill', () => {
|
|
const { root, skillsDir } = scratchRepo();
|
|
const plan = planScaffold({
|
|
skillsDir,
|
|
repoRoot: root,
|
|
vars: {
|
|
name: 'hello-world',
|
|
description: 'say hello',
|
|
triggers: ['say hello', 'greet me'],
|
|
writesTo: [],
|
|
writesPages: false,
|
|
mutating: false,
|
|
},
|
|
});
|
|
expect(plan.files.length).toBe(4);
|
|
const paths = plan.files.map(f => f.path);
|
|
expect(paths).toContain(join(skillsDir, 'hello-world', 'SKILL.md'));
|
|
expect(paths).toContain(
|
|
join(skillsDir, 'hello-world', 'scripts', 'hello-world.mjs'),
|
|
);
|
|
expect(paths).toContain(join(skillsDir, 'hello-world', 'routing-eval.jsonl'));
|
|
expect(paths).toContain(join(root, 'test', 'hello-world.test.ts'));
|
|
expect(plan.files.every(f => f.kind === 'new')).toBe(true);
|
|
expect(plan.resolverFile).toBe(join(skillsDir, 'RESOLVER.md'));
|
|
expect(plan.resolverAppend).not.toBeNull();
|
|
expect(plan.resolverAppend!).toContain('`skills/hello-world/SKILL.md`');
|
|
});
|
|
|
|
it('SKILL.md includes the SKILLIFY_STUB sentinel', () => {
|
|
const { root, skillsDir } = scratchRepo();
|
|
const plan = planScaffold({
|
|
skillsDir,
|
|
repoRoot: root,
|
|
vars: {
|
|
name: 'foo',
|
|
description: 'foo skill',
|
|
triggers: [],
|
|
writesTo: [],
|
|
writesPages: false,
|
|
mutating: false,
|
|
},
|
|
});
|
|
const skillMd = plan.files.find(f => f.path.endsWith('SKILL.md'))!;
|
|
expect(skillMd.content).toContain(SKILLIFY_STUB_MARKER);
|
|
});
|
|
|
|
it('script stub includes the SKILLIFY_STUB sentinel (D-CX-9 gate hook)', () => {
|
|
const { root, skillsDir } = scratchRepo();
|
|
const plan = planScaffold({
|
|
skillsDir,
|
|
repoRoot: root,
|
|
vars: {
|
|
name: 'foo',
|
|
description: 'foo skill',
|
|
triggers: [],
|
|
writesTo: [],
|
|
writesPages: false,
|
|
mutating: false,
|
|
},
|
|
});
|
|
const script = plan.files.find(f => f.path.endsWith('foo.mjs'))!;
|
|
expect(script.content).toContain(SKILLIFY_STUB_MARKER);
|
|
});
|
|
|
|
it('refuses to scaffold over an existing file without --force', () => {
|
|
const { root, skillsDir } = scratchRepo();
|
|
mkdirSync(join(skillsDir, 'existing'), { recursive: true });
|
|
writeFileSync(
|
|
join(skillsDir, 'existing', 'SKILL.md'),
|
|
'---\nname: existing\n---\n',
|
|
);
|
|
expect(() =>
|
|
planScaffold({
|
|
skillsDir,
|
|
repoRoot: root,
|
|
vars: {
|
|
name: 'existing',
|
|
description: 'x',
|
|
triggers: [],
|
|
writesTo: [],
|
|
writesPages: false,
|
|
mutating: false,
|
|
},
|
|
}),
|
|
).toThrow(SkillifyScaffoldError);
|
|
});
|
|
|
|
it('--force marks existing files as overwrite', () => {
|
|
const { root, skillsDir } = scratchRepo();
|
|
mkdirSync(join(skillsDir, 'existing'), { recursive: true });
|
|
writeFileSync(
|
|
join(skillsDir, 'existing', 'SKILL.md'),
|
|
'---\nname: existing\n---\n',
|
|
);
|
|
const plan = planScaffold({
|
|
skillsDir,
|
|
repoRoot: root,
|
|
force: true,
|
|
vars: {
|
|
name: 'existing',
|
|
description: 'x',
|
|
triggers: ['foo'],
|
|
writesTo: [],
|
|
writesPages: false,
|
|
mutating: false,
|
|
},
|
|
});
|
|
const skillMd = plan.files.find(f => f.path.endsWith('SKILL.md'))!;
|
|
expect(skillMd.kind).toBe('overwrite');
|
|
});
|
|
|
|
it('D-CX-7: resolverAppend is null when row already present (idempotent)', () => {
|
|
const { root, skillsDir } = scratchRepo();
|
|
// Prime the resolver with an existing row for the skill we're about
|
|
// to scaffold. The plan must NOT queue a duplicate append, even
|
|
// when --force regenerates files.
|
|
const resolverPath = join(skillsDir, 'RESOLVER.md');
|
|
const before = readFileSync(resolverPath, 'utf-8');
|
|
writeFileSync(
|
|
resolverPath,
|
|
before +
|
|
'\n## Uncategorized\n\n| Trigger | Skill |\n|---------|-------|\n' +
|
|
'| "do thing" | `skills/demo/SKILL.md` |\n',
|
|
);
|
|
const plan = planScaffold({
|
|
skillsDir,
|
|
repoRoot: root,
|
|
vars: {
|
|
name: 'demo',
|
|
description: 'demo',
|
|
triggers: ['do thing'],
|
|
writesTo: [],
|
|
writesPages: false,
|
|
mutating: false,
|
|
},
|
|
});
|
|
expect(plan.resolverAppend).toBeNull();
|
|
});
|
|
|
|
it('detects bare-path resolver row (no backticks) → no duplicate append', () => {
|
|
// User hand-edited the resolver to drop backticks. The original
|
|
// backtick-only matcher missed this; broadened matcher catches it.
|
|
const { root, skillsDir } = scratchRepo();
|
|
const resolverPath = join(skillsDir, 'RESOLVER.md');
|
|
const before = readFileSync(resolverPath, 'utf-8');
|
|
writeFileSync(
|
|
resolverPath,
|
|
before +
|
|
'\n## Uncategorized\n\n| Trigger | Skill |\n|---------|-------|\n' +
|
|
'| "do thing" | skills/demo/SKILL.md |\n',
|
|
);
|
|
const plan = planScaffold({
|
|
skillsDir,
|
|
repoRoot: root,
|
|
vars: { name: 'demo', description: 'd', triggers: ['do thing'], writesTo: [], writesPages: false, mutating: false },
|
|
});
|
|
expect(plan.resolverAppend).toBeNull();
|
|
});
|
|
|
|
it('detects double-quoted resolver row → no duplicate append', () => {
|
|
const { root, skillsDir } = scratchRepo();
|
|
const resolverPath = join(skillsDir, 'RESOLVER.md');
|
|
const before = readFileSync(resolverPath, 'utf-8');
|
|
writeFileSync(
|
|
resolverPath,
|
|
before +
|
|
'\n## Uncategorized\n\n| Trigger | Skill |\n|---------|-------|\n' +
|
|
'| "do thing" | "skills/demo/SKILL.md" |\n',
|
|
);
|
|
const plan = planScaffold({
|
|
skillsDir,
|
|
repoRoot: root,
|
|
vars: { name: 'demo', description: 'd', triggers: ['do thing'], writesTo: [], writesPages: false, mutating: false },
|
|
});
|
|
expect(plan.resolverAppend).toBeNull();
|
|
});
|
|
|
|
it('detects single-quoted resolver row → no duplicate append', () => {
|
|
const { root, skillsDir } = scratchRepo();
|
|
const resolverPath = join(skillsDir, 'RESOLVER.md');
|
|
const before = readFileSync(resolverPath, 'utf-8');
|
|
writeFileSync(
|
|
resolverPath,
|
|
before +
|
|
"\n## Uncategorized\n\n| Trigger | Skill |\n|---------|-------|\n" +
|
|
"| \"do thing\" | 'skills/demo/SKILL.md' |\n",
|
|
);
|
|
const plan = planScaffold({
|
|
skillsDir,
|
|
repoRoot: root,
|
|
vars: { name: 'demo', description: 'd', triggers: ['do thing'], writesTo: [], writesPages: false, mutating: false },
|
|
});
|
|
expect(plan.resolverAppend).toBeNull();
|
|
});
|
|
|
|
it('does NOT false-match a longer skill name with a shared prefix', () => {
|
|
// If "demo" is the target but resolver only references
|
|
// "demo-extended", we MUST treat that as no existing row (different
|
|
// skill). Broadened matcher uses anchored boundaries to prevent this.
|
|
const { root, skillsDir } = scratchRepo();
|
|
const resolverPath = join(skillsDir, 'RESOLVER.md');
|
|
const before = readFileSync(resolverPath, 'utf-8');
|
|
writeFileSync(
|
|
resolverPath,
|
|
before +
|
|
'\n## Uncategorized\n\n| Trigger | Skill |\n|---------|-------|\n' +
|
|
'| "do extended" | `skills/demo-extended/SKILL.md` |\n',
|
|
);
|
|
const plan = planScaffold({
|
|
skillsDir,
|
|
repoRoot: root,
|
|
vars: { name: 'demo', description: 'd', triggers: ['do thing'], writesTo: [], writesPages: false, mutating: false },
|
|
});
|
|
expect(plan.resolverAppend).not.toBeNull();
|
|
});
|
|
|
|
it('handles --triggers omitted by seeding TBD placeholder', () => {
|
|
const { root, skillsDir } = scratchRepo();
|
|
const plan = planScaffold({
|
|
skillsDir,
|
|
repoRoot: root,
|
|
vars: {
|
|
name: 'empty-triggers',
|
|
description: 'test',
|
|
triggers: [],
|
|
writesTo: [],
|
|
writesPages: false,
|
|
mutating: false,
|
|
},
|
|
});
|
|
const skillMd = plan.files.find(f => f.path.endsWith('SKILL.md'))!;
|
|
expect(skillMd.content).toContain('TBD-trigger');
|
|
});
|
|
|
|
it('writes_pages + writes_to flow through to frontmatter', () => {
|
|
const { root, skillsDir } = scratchRepo();
|
|
const plan = planScaffold({
|
|
skillsDir,
|
|
repoRoot: root,
|
|
vars: {
|
|
name: 'writer',
|
|
description: 'writer',
|
|
triggers: ['write me'],
|
|
writesTo: ['people/', 'companies/'],
|
|
writesPages: true,
|
|
mutating: true,
|
|
},
|
|
});
|
|
const skillMd = plan.files.find(f => f.path.endsWith('SKILL.md'))!;
|
|
expect(skillMd.content).toContain('writes_pages: true');
|
|
expect(skillMd.content).toContain('- people/');
|
|
expect(skillMd.content).toContain('- companies/');
|
|
expect(skillMd.content).toContain('mutating: true');
|
|
});
|
|
});
|
|
|
|
describe('applyScaffold', () => {
|
|
it('writes all planned files and appends the resolver row', () => {
|
|
const { root, skillsDir } = scratchRepo();
|
|
const plan = planScaffold({
|
|
skillsDir,
|
|
repoRoot: root,
|
|
vars: {
|
|
name: 'hello',
|
|
description: 'hi',
|
|
triggers: ['say hi'],
|
|
writesTo: [],
|
|
writesPages: false,
|
|
mutating: false,
|
|
},
|
|
});
|
|
applyScaffold(plan);
|
|
for (const f of plan.files) {
|
|
expect(existsSync(f.path)).toBe(true);
|
|
}
|
|
const resolver = readFileSync(join(skillsDir, 'RESOLVER.md'), 'utf-8');
|
|
expect(resolver).toContain('`skills/hello/SKILL.md`');
|
|
});
|
|
|
|
it('second apply with same name + --force overwrites files but does NOT duplicate resolver row (D-CX-7)', () => {
|
|
const { root, skillsDir } = scratchRepo();
|
|
|
|
const firstPlan = planScaffold({
|
|
skillsDir,
|
|
repoRoot: root,
|
|
vars: {
|
|
name: 'idem',
|
|
description: 'first',
|
|
triggers: ['t'],
|
|
writesTo: [],
|
|
writesPages: false,
|
|
mutating: false,
|
|
},
|
|
});
|
|
applyScaffold(firstPlan);
|
|
|
|
const secondPlan = planScaffold({
|
|
skillsDir,
|
|
repoRoot: root,
|
|
force: true,
|
|
vars: {
|
|
name: 'idem',
|
|
description: 'second',
|
|
triggers: ['t'],
|
|
writesTo: [],
|
|
writesPages: false,
|
|
mutating: false,
|
|
},
|
|
});
|
|
expect(secondPlan.resolverAppend).toBeNull();
|
|
applyScaffold(secondPlan);
|
|
|
|
const resolver = readFileSync(join(skillsDir, 'RESOLVER.md'), 'utf-8');
|
|
const count = (resolver.match(/`skills\/idem\/SKILL\.md`/g) ?? []).length;
|
|
expect(count).toBe(1);
|
|
});
|
|
|
|
it('applies against an AGENTS.md-layout workspace (W1 interop)', () => {
|
|
const root = mkdtempSync(join(tmpdir(), 'skillify-openclaw-'));
|
|
created.push(root);
|
|
const skillsDir = join(root, 'workspace', 'skills');
|
|
mkdirSync(skillsDir, { recursive: true });
|
|
mkdirSync(join(root, 'test'), { recursive: true });
|
|
// AGENTS.md at workspace root, NOT inside skills/.
|
|
writeFileSync(
|
|
join(root, 'workspace', 'AGENTS.md'),
|
|
'# AGENTS\n\n## Ops\n\n| Trigger | Skill |\n|---------|-------|\n',
|
|
);
|
|
const plan = planScaffold({
|
|
skillsDir,
|
|
repoRoot: root,
|
|
vars: {
|
|
name: 'openclaw-demo',
|
|
description: 'demo',
|
|
triggers: ['do it'],
|
|
writesTo: [],
|
|
writesPages: false,
|
|
mutating: false,
|
|
},
|
|
});
|
|
expect(plan.resolverFile).toBe(join(root, 'workspace', 'AGENTS.md'));
|
|
expect(plan.resolverAppend).not.toBeNull();
|
|
applyScaffold(plan);
|
|
const agents = readFileSync(join(root, 'workspace', 'AGENTS.md'), 'utf-8');
|
|
expect(agents).toContain('`skills/openclaw-demo/SKILL.md`');
|
|
});
|
|
});
|