Files
gbrain/test/skillify-scaffold.test.ts
T
a1a2671c21 v0.28.4 feat(skillpack): enhance skillify with cross-modal eval quality gate (#674)
* 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>
2026-05-06 20:39:56 -07:00

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);
});
});