mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* feat(skillpack): enhance skillify with cross-modal eval quality gate Updates skillify from v1.0.0 to v2.0.0 with the key innovation: cross-modal evaluation runs BEFORE tests (step 3) to establish quality, then tests lock in the proven-good behavior. Key changes: - 11-item checklist (was 10) - adds cross-modal eval as step 3 - Cross-modal eval uses 3 models to score output on 5 dimensions - Quality gate: all dimensions ≥ 7 average before proceeding to tests - Prevents locking in mediocrity through tests-first approach - References cross-modal-review skill for eval pipeline - Updated all gbrain-specific paths (bun test, scripts/*.ts) - Maintains compatibility with gbrain check-resolvable workflow The meta-skill for turning raw features into properly-skilled, tested, resolvable capabilities. Cross-modal eval ensures output quality before tests cement the behavior. * feat: skillify hardened via 2 cross-modal eval cycles (8.1/10) Applied top improvements from GPT-5.5 + Opus 4-7 + DeepSeek V4 Pro: - Named 3 frontier models explicitly with provider table - Inlined eval prompt template with CONTEXT param + scoring calibration - Defined aggregation math: mean >= 7 AND no single dim < 5 - Added eval receipt JSON schema - Structured 3-cycle fix loop with before/after delta tracking - Added worked example (summarize-pr, end-to-end) - Added cost guardrails (skip < 200 tokens, max 9 API calls) - Added representative input selection rule - Added SKILL.md frontmatter template (copy-paste ready) - Added Phase 0 decision gate (is this worth skillifying?) Also includes cross-modal-eval runner recipe with robust JSON parsing for LLMs that return malformed JSON (3-tier repair). * chore(recipes): remove cross-modal-eval.mjs Superseded by `gbrain eval cross-modal` (next commit). The .mjs script was the original PR's hand-rolled provider stack; the replacement reuses src/core/ai/gateway.ts so config/auth/model-aliasing comes from the canonical recipe registry instead of a parallel stack. No code references the .mjs (it was invoked by skill prose only), so this delete is independently safe to bisect through. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(eval): cross-modal-eval core module + unit tests Pure-logic foundation for the new `gbrain eval cross-modal` command (wired in the next commit). All five modules are self-contained — no CLI surface, no I/O outside the receipt writer's mkdirSync. Imported from src/core/ai/gateway.ts at runtime via gwChat (no config impact at load time). Modules: - json-repair.ts: parseModelJSON 4-strategy fallback chain. Adversarial nuclear-option throws rather than fabricating scores (Q6 + Q3 in plan). - aggregate.ts: verdict logic. PASS = (>=2 successes) AND (every dim mean >= 7) AND (every dim min across models >= 5). INCONCLUSIVE when <2/3 models returned parseable scores — closes the v1 .mjs `Object.values({}).every(...) === true` empty-array silent-PASS bug (Q2 + Q3). - receipt-name.ts: receipt filename binds (slug, sha8 of SKILL.md) so `gbrain skillify check` can detect stale audits (T10 in plan). - receipt-write.ts: thin wrapper over writeFileSync that auto-mkdirs the parent directory. Standalone module because gbrainPath() does NOT auto-mkdir (T5 plan correction — Codex caught this). - runner.ts: orchestrator. Promise.allSettled across 3 slots per cycle; up to 3 cycles; stops early on PASS or INCONCLUSIVE. Default slots: openai:gpt-4o / anthropic:claude-opus-4-7 / google:gemini-1.5-pro. estimateCost() exports a small per-model pricing table (drifts; refresh alongside model-family bumps). Tests (32 cases total, all green): - json-repair.test.ts: 10 cases (clean JSON, fences, trailing commas, single quotes, embedded newlines, mismatched braces, nuclear-option success + adversarial throws, empty input, numeric-shorthand scores). - aggregate.test.ts: 8 cases pinning Q2/Q3/dedup. The 0-of-3 INCONCLUSIVE case is the regression guard for the v1 silent-PASS bug. - cli.test.ts: 12 cases on receipt-name / receipt-write / GBRAIN_HOME isolation. Uses withEnv() helper for env mutation (R1 isolation rule). Verifies bisect-clean: typecheck passes, all 32 unit cases green. The runner.ts import of gateway.chat() is dead until commit 3 wires the CLI surface. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(eval): wire `gbrain eval cross-modal` CLI subcommand User-facing surface for the multi-model quality gate. Three different- provider frontier models score the OUTPUT against the TASK on a 5-dim rubric. Verdict drives exit code: 0 PASS, 1 FAIL, 2 INCONCLUSIVE (<2/3 models returned parseable scores per Q3 in plan). Wiring touches three files: - src/commands/eval-cross-modal.ts (new, ~290 lines) CLI handler. Self-configures the AI gateway from loadConfig() + process.env so it works without `gbrain init` (the cli.ts no-DB branch bypasses connectEngine()). Defaults: cycles=3 in TTY, cycles=1 in non-TTY (T11 partial cost guardrail — limits scripted bulk spend; full --budget-usd hard cap is a v0.27.x TODO). Prints estimated max-cost-per-cycle to stderr before each run. Uses gbrainPath('eval-receipts') for receipt directory. - src/cli.ts (no-DB dispatch branch, 5-line addition) Special-cases `eval cross-modal` BEFORE the existing handleCliOnly path that requires connectEngine(). Mirrors the `dream` no-DB pattern but doesn't even attempt the connect — the command never touches the DB. New users can run the gate before `gbrain init` (T3 in plan). - src/commands/eval.ts (sub-subcommand dispatch) Adds `cross-modal` alongside `export`/`prune`/`replay`. The cli.ts branch takes precedence in the user-facing path; this branch only fires when callers re-enter runEvalCommand with an existing engine. Engine is intentionally unused — the handler self-routes. - test/e2e/cross-modal-eval.test.ts (new, 4 cases) Mocked-fetch E2E. Lives at test/e2e/* (NOT *.serial.test.ts) per plan T8: test/e2e/* is exempt from the test-isolation lint and already runs serially via scripts/run-e2e.sh, so the mock.module() call doesn't need a quarantine rename. Cases: PASS / FAIL (mean<7) / FAIL (min<5 — Q2 floor) / INCONCLUSIVE (2 mock 5xx — Q3 contract). The runner from commit 2 now has live callers. typecheck passes; the 4 E2E cases all green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(skillify): add informational 11th item (cross-modal eval) Promotes the skillify contract from 10 to 11 items. The 11th item (cross-modal eval) is `required:false` per T7 in the plan — a missing or stale receipt surfaces in the audit output but does not fail the gate. Existing skills keep their current required-score; the bump is additive, not breaking. Changes: - src/commands/skillify.ts Header jsdoc updated 10-item -> 11-item. No code-flow changes. - src/commands/skillify-check.ts (the per-skill audit; not src/commands/skillpack-check.ts which is a different command — plan T6 corrected the conflation in the original plan) New informational item at position 11. Reuses findReceiptForSkill() helper from src/core/cross-modal-eval/receipt-name.ts to detect: * found — receipt matches current SKILL.md sha-8 * stale — receipt exists for an older SKILL.md * missing — no receipt yet Audit output cases pass through to existing pretty/JSON formats. - src/core/skillify/templates.ts Scaffolded SKILL.md now includes a "Phase 3: Cross-modal eval (informational)" section with copy-paste `gbrain eval cross-modal` invocation, pass criteria, and receipt-naming convention. Helps new skill authors discover the gate. - test/skillify-scaffold.test.ts New T9 case verifies the scaffold emits the Phase 3 section, points at the correct command, documents the receipt path, and appends exactly one resolver row. Replaces the original plan's `gbrain skillify scaffold demo-eleven` shell verification (which Codex caught as invalid + repo-mutating). Verifies: typecheck passes; scaffold test 19/19 (was 18, +1 T9 case). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: skillify v1.1.0 + cross-modal-eval references Documentation catches up with the new behavior shipped in commits 1-4. - skills/skillify/SKILL.md (1.0.0 -> 1.1.0) Full rewrite. Frontmatter version is additive (T7 in plan); the 11th item is informational, not breaking. Phase 3 now points at `gbrain eval cross-modal` with copy-paste invocation, default slot table, pass criteria, receipt-naming convention, cycles + cost guardrails (T11 partial cap), provider configuration via the AI gateway, and the cycle-1/2/3 fix loop. Adds Output Format section (skills-conformance.test.ts requires it). Drops the original `(or lib/cross-modal-eval.ts)` parenthetical (Q5 plan correction — that path never existed). - skills/cross-modal-review/SKILL.md Adds 4-line Relationship section pointing at `gbrain eval cross-modal` (D3 plan reciprocal). Distinguishes the manual second-opinion gate (this skill) from the automated multi-model score-and-iterate gate (the new command). - CLAUDE.md Key Files entries for src/commands/eval-cross-modal.ts and the five new src/core/cross-modal-eval/* modules. Commands list gains the `gbrain eval cross-modal` entry under v0.27.x. Notes the non-TTY default 1-cycle behavior + the gbrainPath('eval- receipts') resolution. - TODOS.md Four v0.27.x follow-ups filed under a new "cross-modal-eval" section: full --budget-usd cap (T11 follow-up), subagent integration (recovers cross-process rate-leases T4 deferred), skill adoption telemetry (revisit T7=C with data after 30 days), docs/cross-modal-eval.md user guide. - llms-full.txt Regenerated via `bun run build:llms` to match the CLAUDE.md edits — sync guard at test/build-llms.test.ts requires this. Verifies: typecheck passes; skills-conformance 199/199 green; build-llms 7/7 green; full unit fast loop 3861/3861 green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: bump version and changelog (v0.28.4) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: garrytan-agents <garrytan-agents@users.noreply.github.com> Co-authored-by: Garry Tan <garrytan@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
463 lines
15 KiB
TypeScript
463 lines
15 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`');
|
|
});
|
|
});
|
|
|
|
describe('11-item scaffold contract (T9 + Phase 3 cross-modal eval)', () => {
|
|
it('scaffolded SKILL.md teaches the cross-modal eval Phase 3', () => {
|
|
// T9 (plans/radiant-napping-lerdorf.md): non-mutating verification of
|
|
// the 11-item contract bump. Calls planScaffold + applyScaffold against
|
|
// an in-memory tempdir, then asserts the produced SKILL.md mentions
|
|
// cross-modal eval. No `gbrain skillify scaffold demo-eleven` shell-out
|
|
// (which would mutate the real repo and require --description).
|
|
const { root, skillsDir } = scratchRepo();
|
|
const plan = planScaffold({
|
|
skillsDir,
|
|
vars: {
|
|
name: 'phase-three-demo',
|
|
description: 'demo skill that verifies Phase 3 lands in scaffold output',
|
|
triggers: ['phase three demo'],
|
|
writesTo: [],
|
|
writesPages: false,
|
|
mutating: false,
|
|
},
|
|
});
|
|
applyScaffold(plan);
|
|
|
|
const skillMdPath = join(skillsDir, 'phase-three-demo', 'SKILL.md');
|
|
const body = readFileSync(skillMdPath, 'utf-8');
|
|
|
|
expect(body).toContain('## Phase 3: Cross-modal eval');
|
|
expect(body).toContain('gbrain eval cross-modal');
|
|
expect(body).toContain('skills/phase-three-demo/SKILL.md');
|
|
// Receipts naming convention is documented in the scaffold so the
|
|
// implementer knows where to look.
|
|
expect(body).toContain('eval-receipts');
|
|
expect(body).toContain('<sha8>');
|
|
// Pass criterion is documented in the scaffold (Q2 floor + Q3 floor).
|
|
expect(body).toMatch(/dim mean\s*>=\s*7/i);
|
|
expect(body).toMatch(/no model scored any dim\s*<\s*5/i);
|
|
// Resolver row is appended once and only once — the verification step
|
|
// does not need any cleanup beyond the tempdir afterEach drops.
|
|
const resolverContent = readFileSync(join(skillsDir, 'RESOLVER.md'), 'utf-8');
|
|
const occurrences = resolverContent.split('skills/phase-three-demo/SKILL.md').length - 1;
|
|
expect(occurrences).toBe(1);
|
|
});
|
|
});
|