Files
gbrain/test/e2e/skillopt-loop.serial.test.ts
T
d4211f4176 v0.42.11.0 feat(skillopt): held-out eval gate, honest receipts, ENFORCE + ablation opts (#1759)
* feat(skillopt): wire held-out gate, honest receipts, ENFORCE + ablation opts

Wire the F11 held-out gate into the orchestrator at checkpoint acceptance
(runHeldOutGate was dead code); parse + thread --held-out through CLI, batch,
fleet, background job, and the run_skillopt MCP op. Populate the real
receipt.baseline_sel_score (was hardcoded 0) and add a final-test eval
(test_score + baseline_test_score) via a shared scoreSkillOnTasks primitive.
Fix the --no-mutate proposed.md write (was a stub) and enforce maxRuntimeMin.

D16 ENFORCE in core mutation policy (assertBundledMutationHeldOut): mutating a
bundled skill in place requires a non-empty (>=5), benchmark-disjoint held-out
set or hard-refuses. Add three eval-internal ablation opts (reflectMode,
disableValidationGate, optimizerMode='one-shot-rewrite') recorded in the
receipt + audit; ROLLOUT_SUCCESS_THRESHOLD named constant.

Security: run_skillopt MCP op validates skill_name (kebab-only) and confines
caller-supplied benchmark/held-out paths to the skills dir for remote callers.

* test(skillopt): held-out gate, ENFORCE, one-shot rewrite, runtime + receipt honesty

New test/skillopt/rollout.test.ts (rollout had zero coverage). Held-out ENFORCE
unit cases + one-shot-rewrite fence handling (whole-response unwrap, embedded-fence
preserved, error path). E2E: F11 held-out BLOCKS/ALLOWS, bundled no-mutate write,
reflectMode/disableValidationGate/optimizerMode, maxRuntimeMin abort, receipt
baseline/test-score honesty, held-out/benchmark disjointness, D2 no-DB-pollution.

* chore: bump version and changelog (v0.42.9.0)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: document skillopt held-out gate + bundled mutation requirement for v0.42.9.0

Wire --held-out into the skill-optimizer SKILL.md, guide flags/safety tables, and
the tutorial's bundled-skill step: mutating a bundled skill in place now requires
--allow-mutate-bundled AND --held-out (>=5 benchmark-disjoint tasks) or it
hard-refuses. Add the --held-out flag row + F11 held-out gate to the guide; update
the receipt contract to the honest baseline/test-score fields.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(gateway): AI SDK v6 toolLoop compat — multi-turn tool calls work again

The ai@6.x bump tightened ModelMessage + tool-schema validation, which
silently broke every multi-turn tool loop. Both `gbrain skillopt` rollouts
and production background `subagent` jobs route through `chat()`/`toolLoop`
and crashed the moment the model called a tool ("messages do not match the
ModelMessage[] schema" / "schema is not a function"). Surfaced end-to-end
by the SkillOpt real-LLM eval.

Three fixes:
- chat(): wrap tool defs with the SDK's `jsonSchema()` helper instead of a
  bare `{jsonSchema}` object (v6 asSchema() treated the bare object as a
  thunk and threw).
- chat(): new exported pure `toModelMessages()` converts gbrain's
  provider-neutral ChatMessage[] into v6 ModelMessage[] — tool results ride
  a dedicated `role:'tool'` message with structured `{type,value}` output;
  null output preserved as json null. Load-bearing for the production
  subagent path, not just skillopt.
- rollout.ts: replace the inline params→schema mapper (dropped `items` on
  array params) with the shared `paramDefToSchema` single source of truth.

Pinned by test/gateway-model-messages.test.ts (8 cases). Folds into the
open v0.42.9.0 PR (#1759) — these complete the eval-readiness wave by
making skillopt actually run against a live model.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(skillopt): budget no-pricing for Haiku silently scored every rollout 0

Surfaced by the SkillOpt real-LLM eval (Track B). Two coupled bugs that made
a budget-capped Haiku run report a vacuous "0/N" measurement in ~2ms with
zero LLM calls — indistinguishable from a real deficient-skill score:

1. Claude Haiku 4.5's canonical dateless id (`claude-haiku-4-5`) was missing
   from anthropic-pricing.ts (only the dated `-20251001` was present). With
   `--max-cost` set, BudgetTracker.reserve() threw no_pricing on the FIRST
   chat() of every rollout. Added the dateless entry (sonnet already had its
   dateless form).
2. runValidationGate swallowed that BUDGET_EXHAUSTED error — runWithLimit
   settled it as {ok:false}, which the gate turned into median:0. A pricing/cap
   crash became a fake score. The gate now scans settled results for
   isMustAbortError() and re-throws so the caller aborts loudly; ordinary
   (non-abort) rollout errors still fail-open to 0 (judge-hiccup posture kept).

Pinned by test/skillopt/validate-gate-abort.test.ts (3 cases). Folds into the
open v0.42.9.0 PR (#1759).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(ci): llms-full.txt over size budget — drop what-schemas-unlock from full bundle

The toolLoop + budget bug-fix annotations grew CLAUDE.md, pushing llms-full.txt
to 756KB over the 750KB FULL_SIZE_BUDGET (the `build-llms > size budget` test
failed, failing the `test` CI job). CLAUDE.md stays inlined by design (it's the
point of the one-fetch bundle), so per the budget comment's own guidance ("ship
with includeInFull=false exclusions") this excludes docs/what-schemas-unlock.md
(15.4KB value-explainer, not load-bearing operational reference) from
llms-full.txt; it stays linked in llms.txt. Bundle now 740KB with ~9KB headroom.
No budget bump — 750KB is near the ~190k-token-context fit ceiling.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(ci): re-admit policy docs into ci-cache-hash before doc relocation

docs/**/*.md is deny-listed from the CI cache hash (test-irrelevant). The
CLAUDE.md restructure moves test/release POLICY into docs/TESTING.md +
docs/RELEASING.md, which DO carry contracts the test suite reads. Without
re-admitting them, a policy-only edit would produce the same cache hash and
skip the test shard that runs the build-llms + doc-history guards (false-pass).

Adds an ALLOW_PATTERNS re-admit step after the deny, scoped to the named
policy docs (not a blanket docs un-deny). Lands FIRST, before any doc moves.

Pinned by 3 new cases in test/scripts/ci-cache-hash.test.ts: TESTING.md +
RELEASING.md edits MUST change the hash; docs/guide.md still must not.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(docs): relocate Key files / thin-client / Testing out of CLAUDE.md (verbatim)

CLAUDE.md had grown to 592KB / ~147k tokens auto-loaded every session (~77% of
the llms-full.txt single-fetch bundle). The per-file index was append-only by
mandate. This is the exact thin-dispatcher-vs-fat-blob anti-pattern gbrain exists
to fix, so CLAUDE.md becomes a thin orientation + resolver that points at
on-demand docs.

This commit is the VERBATIM move (content-preserving — the next commit compresses):
- docs/architecture/KEY_FILES.md   <- ## Key files + the calibration key-files
  cluster + Schema Cathedral v3 impl detail
- docs/architecture/thin-client.md <- ## Thin-client routing
- docs/TESTING.md                   <- ## Testing
- ## Commands DROPPED (18 'added in vX.Y' history blocks; current surface is
  gbrain 0.41.38.0 -- personal knowledge brain

USAGE
  gbrain <command> [options]

SETUP
  init [--pglite|--supabase|--url]   Create brain (PGLite default, no server)
  migrate --to <supabase|pglite>     Transfer brain between engines
  upgrade                            Self-update
  check-update [--json]              Check for new versions
  doctor [--json] [--fast]            Health check (resolver, skills, pgvector, RLS, embeddings)
  integrations [subcommand]          Manage integration recipes (senses + reflexes)

PAGES
  get <slug>                         Read a page
  put <slug> [< file.md]             Write/update a page
  delete <slug>                      Delete a page
  list [--type T] [--tag T] [-n N]   List pages

SEARCH
  search <query>                     Keyword search (tsvector)
  query <question> [--no-expand]     Hybrid search (RRF + expansion)
  ask <question> [--no-expand]       Alias for query

IMPORT/EXPORT
  import <dir> [--no-embed]          Import markdown directory
  sync [--repo <path>] [flags]       Git-to-brain incremental sync
  sync --watch [--interval N]        Continuous sync (loops until stopped)
  sync --install-cron                Install persistent sync daemon
  export [--dir ./out/]              Export to markdown
  export --restore-only [--repo <p>] Restore missing supabase-only files
        [--type T] [--slug-prefix S] With optional filters

FILES
  files list [slug]                  List stored files
  files upload <file> --page <slug>  Upload file to storage
  files upload-raw <file> --page <s> Smart upload (size routing + .redirect.yaml)
  files signed-url <path>            Generate signed URL (1-hour)
  files sync <dir>                   Bulk upload directory
  files verify                       Verify all uploads

EMBEDDINGS
  embed [<slug>|--all|--stale]       Generate/refresh embeddings

LINKS
  link <from> <to> [--type T]        Create typed link
  unlink <from> <to>                 Remove link
  backlinks <slug>                   Incoming links
  graph <slug> [--depth N]           Traverse link graph (returns nodes)
  graph-query <slug> [--type T]      Edge-based traversal with type/direction filters
        [--depth N] [--direction in|out|both]

TAGS
  tags <slug>                        List tags
  tag <slug> <tag>                   Add tag
  untag <slug> <tag>                 Remove tag

TIMELINE
  timeline [<slug>]                  View timeline
  timeline-add <slug> <date> <text>  Add timeline entry

TOOLS
  extract <links|timeline|all>       Extract links/timeline (idempotent)
        [--source fs|db]             fs (default) walks .md files; db iterates engine pages
        [--dir <brain>]              brain dir for fs source
        [--type T] [--since DATE]    filters (db source)
        [--dry-run] [--json]
  publish <page.md> [--password]     Shareable HTML (strips private data, optional AES-256)
  check-backlinks <check|fix> [dir]  Find/fix missing back-links across brain
  lint <dir|file> [--fix]            Catch LLM artifacts, placeholder dates, bad frontmatter
  orphans [--json] [--count]         Find pages with no inbound wikilinks
  salience [--days N] [--kind P]     v0.29: pages ranked by emotional + activity salience
  anomalies [--since D] [--sigma N]  v0.29: cohort-based statistical anomalies (tag, type)
  transcripts recent [--days N]      v0.29: recent raw .txt transcripts (local-only)
  dream [--dry-run] [--json]         Run the overnight maintenance cycle once (cron-friendly).
                                     See also: autopilot --install (continuous daemon).
  check-resolvable [--json] [--fix]  Validate skill tree (reachability/MECE/DRY)
  report --type <name> --content ... Save timestamped report to brain/reports/

BRAIN (capture / ideate / explore — v0.37/v0.38)
  capture [content] [--file PATH]    Single entrypoint for getting content into the brain
        [--stdin] [--slug s] [--type t]   Inline content / file / stdin; writes to inbox/ by default
        [--source ID] [--quiet|--json]    Multi-source brains: route to a non-default source
  brainstorm <question> [--json]     Bisociation idea generator (hybrid search + far-set + judge)
        [--save|--no-save] [--limit N]
  lsd <question> [--json]            Lateral Synaptic Drift: inverted-judge brainstorm
        [--save|--no-save] [--limit N]    rewarding far-from-obvious + axiomatic inversions

SOURCES (multi-repo / multi-brain)
  sources list                       Show registered sources
  sources add <id> --path <p>        Register a source (id = short name, e.g. 'wiki')
  sources remove <id>                Remove a source + its pages
  sync --all                         Sync all sources with a local_path
  sync --source <id>                 Sync one specific source
  repos ...                          DEPRECATED alias for 'sources' (v0.19.0)

CODE INDEXING (v0.19.0 / v0.20.0 Cathedral II)
  code-def <symbol> [--lang l]       Find the definition of a symbol across code pages
  code-refs <symbol> [--lang l]      Find all references to a symbol (JSON-first)
  code-callers <symbol>              Who calls this symbol? (v0.20.0 A1)
  code-callees <symbol>              What does this symbol call? (v0.20.0 A1)
  query <q> --lang <l>               Filter hybrid search to one language (v0.20.0)
  query <q> --symbol-kind <k>        Filter to symbol type (function|class|method|...) (v0.20.0)
  reconcile-links [--dry-run]        Batch-recompute doc↔impl edges (v0.20.0)
  reindex-code [--source id] [--yes] Explicit code-page reindex (v0.20.0)
  sync --strategy code               Sync code files into the brain

JOBS (Minions)
  jobs submit <name> [--params JSON]  Submit background job [--follow] [--dry-run]
  jobs list [--status S] [--limit N]  List jobs
  jobs get <id>                       Job details + history
  jobs cancel <id>                    Cancel job
  jobs retry <id>                     Re-queue failed/dead job
  jobs prune [--older-than 30d]       Clean old jobs
  jobs stats                          Job health dashboard
  jobs work [--queue Q]               Start worker daemon (Postgres only)

ADMIN
  stats                              Brain statistics
  health                             Brain health dashboard
  history <slug>                     Page version history
  revert <slug> <version-id>         Revert to version
  features [--json] [--auto-fix]     Scan usage + recommend unused features
  autopilot [--repo] [--interval N]  Self-maintaining brain daemon
  config [show|get|set] <key> [val]  Brain config
  storage status [--repo <path>]     Storage tier status and health
        [--json]                     (git-tracked vs supabase-only)
  serve                              MCP server (stdio)
  serve --http [--port N]            HTTP MCP server with OAuth 2.1
    --token-ttl N                    Access token TTL in seconds (default: 3600)
    --enable-dcr                     Enable Dynamic Client Registration
    --public-url URL                 Public issuer URL (required behind proxy/tunnel)
  call <tool> '<json>'               Raw tool invocation
  version                            Version info
  --tools-json                       Tool discovery (JSON)

Run gbrain <command> --help for command-specific help. + the per-command KEY_FILES entries; content stays in git)

CLAUDE.md gains: a Reference map (resolver), a Maintaining section (the
anti-disease rule), and a Cross-cutting invariants subsection under Architecture
so the must-never-violate rules (trust fail-closed, sourceScopeOpts isolation,
JSONB trap, engine parity, contract-first, migrations, multi-source) still
auto-load after the index moved out.

Result: CLAUDE.md 592KB -> 61KB; llms-full.txt 740KB -> 210KB (new docs link-only
until compressed). build-llms drift + budget test green; verify 29/29 green.
The pre-move content is recoverable at git show <this^>:CLAUDE.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(docs): compress relocated docs to current-state + add recurrence guard

Compresses the verbatim-relocated reference docs from append-only release-history
to current-state-only (the disease cure), then makes recurrence structurally
impossible via a CI guard.

Compression (fan-out subagents + adversarial verify, audited mechanically):
- KEY_FILES.md 453KB -> 356KB; TESTING.md 42KB -> 38KB; thin-client.md already clean.
- 393/393 entries preserved; every src/test/scripts path from the verbatim original
  survives (mechanical comm-check); zero bolded **v0. markers remain.
- Conservative ratio (~22%) because the content is invariant-dense — correctness
  over brevity. Dropped: **vX.Y.Z (#NNN):** clauses, codex/review tags, contributor
  credits, PR-numbers-as-ids, pre-fix/then/was-now history deltas. Kept: every
  exported symbol, invariant, and Pinned-by reference. Verbatim original recoverable
  at git show <relocation-commit>:docs/architecture/KEY_FILES.md.

Recurrence guard (scripts/check-key-files-current-state.sh, wired into verify + check:all):
- HARD: bans the bolded **v0.<digit> marker in the reference docs (scoped — plain
  'as of pgvector 0.7' prose is fine, no false positives).
- HARD: CLAUDE.md size cap (90KB; currently 61KB) — the structural backstop.
- Pinned by test/scripts/check-key-files-current-state.test.ts (7 cases).

Content contracts (test/build-llms.test.ts, +5 cases per codex outside-voice):
CLAUDE.md keeps inline ship IRON RULES (version format, document-release,
never-hand-roll); AGENTS.md keeps its boot order; llms indexes the new docs;
KEY_FILES stays link-only (not inlined).

Privacy: scrubbed the relocated 'wintermute/chat/' source-boost examples + the
literal harvest-lint regex to generic placeholders (legitimate in allowlisted
CLAUDE.md; genericized for the new public docs per the privacy rule).

Reverts the 284c50a4 band-aid: re-inlines docs/what-schemas-unlock.md now that the
restructure freed ~530KB of bundle headroom (llms-full.txt 740KB -> 225KB).

verify 30/30 green (incl. new check:doc-history).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(docs): relocate verbose release process to docs/RELEASING.md

The highest-/ship-risk commit (isolated so it can revert alone). Moves the verbose
release + contributor procedure out of CLAUDE.md, keeping every ship-critical IRON
RULE inline so /ship + /document-release (which read CLAUDE.md) cannot regress.

Moved to docs/RELEASING.md: pre-ship test requirements; the CHANGELOG-branch-scoped
+ CHANGELOG voice + release-summary template; the 'To take advantage of vX' block
spec; version migrations + migration-is-canonical; schema state tracking; GitHub
Actions SHA maintenance; PR-descriptions-cover-the-branch; community-PR-wave;
checking-out-PRs-from-garrytan-agents.

Kept INLINE in CLAUDE.md (ship-critical IRON RULES — do NOT move):
- the Version-locations table (5-file sync) + the 3-line consistency audit
- Conductor branch=workspace
- Post-ship /document-release (MANDATORY)
- Privacy + Responsible-disclosure rules (Privacy also anchors the check-privacy
  allowlist — the only place allowed to name the fork)
- PR-title-version-first
- never-hand-roll-ship (Skill routing)
Plus a new ## Releasing pointer ('Before any ship, read docs/RELEASING.md in full')
and a resolver row.

CLAUDE.md 61KB -> 39KB (592KB -> 39KB overall, 93% cut; ~9k tokens auto-loaded vs
~147k). CLAUDE.md size-gate tightened 90KB -> 60KB. The content-contract tests pin
that the inline IRON RULES (MAJOR.MINOR.PATCH.MICRO, document-release, hand-roll
ship) did NOT move out. The moved ranges carry no banned fork name, so RELEASING.md
needs no privacy allowlist entry. verify 30/30; bundle 225KB -> 204KB.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(changelog): note CLAUDE.md restructure in v0.42.9.0

The CLAUDE.md thin-resolver restructure (592KB → 39KB) rides in this
release; record it under the existing v0.42.9.0 For-contributors section.
No version bump — v0.42.9.0 is unreleased and already allocated to this PR.

* fix(ci): ci-cache-hash re-admit matched a literal \t, a no-op on GNU grep

The policy-doc re-admit (75992b77) put `\t` inline in the ALLOW patterns
passed to `grep -E`. BSD grep (macOS local) treats `\t` as a tab so it
worked locally; GNU grep (Ubuntu CI) treats it as literal `t`, so nothing
re-admitted and docs/TESTING.md / docs/RELEASING.md stayed deny-listed —
the two policy-doc tests failed on CI shard 6 (1097 pass / 2 fail).

Build ALLOW_RE with `printf '\t(%s)'` so the tab is a real byte, identical
in construction to DENY_RE (line 117), which the CI log shows matches
correctly on GNU grep. End-to-end: editing docs/TESTING.md now flips the
hash; a normal docs/*.md add still does not (deny stays scoped).

* fix(skillopt): feed the scorer's success criteria to the optimizer

Surfaced by the SkillOpt real-LLM eval (Track B). The reflect step was shown
only a pass/fail score and the agent transcript — never WHAT the benchmark
judge rewards. On a skill judged by structure (e.g. "must include a
Confidence: line") the optimizer proposed plausible-but-off edits ("close with
a synthesis") that never satisfied the literal check; every candidate scored 0
on D_sel, the validation gate rejected them all, and the skill text never
changed (optimized === baseline === 0).

Fix: render each benchmark Judge (rule checks / llm rubric / qrels) into
plain-English criteria via new exported describeJudge / describeJudges, and
thread them into the reflect prompt (a SUCCESS CRITERIA block) for both the
loop reflect calls and the one-shot-rewrite path. The orchestrator computes the
distinct criteria across train+sel+test once. The optimizer system prompt now
instructs it to satisfy the criteria through genuine content, never empty
keywords — reward-hacking stays defended by the independent held-out gate
(cat32 confirms the gate catches a keyword-stuffing hack).

End-to-end this took a deficient skill from 0.00 to 1.00 on a held-out set it
never trained on. Pinned by test/skillopt/reflect.test.ts (describeJudge per
kind, describeJudges dedup, criteria present/absent in the prompt). Folds into
the open v0.42.9.0 PR (#1759).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 05:50:21 -07:00

949 lines
40 KiB
TypeScript

/**
* SkillOpt loop E2E: happy path + 5 orchestrator-visible failure modes.
*
* Sibling to `skillopt-pglite.serial.test.ts`. That file pins the three
* v1 paths (dry-run, all-reject, manual revertAllPending). THIS file
* proves the full optimization loop can actually improve a skill end-to-end
* AND that each failure mode the loop is supposed to catch actually does
* the right thing.
*
* Stub strategy: install one composite chat transport via
* `__setChatTransportForTests`. The stub branches on `chatOpts.system`:
*
* - If system starts with "You are SkillOpt's optimizer", the call is
* a reflect call. Branches further on FAILURE vs SUCCESS prompt.
* - Otherwise, the call is a target-agent rollout. The stub emits
* deterministic markdown based on which sections appear in the skill,
* so applied edits change the rollout output → change the score.
*
* Hermetic: no real LLM calls, no `DATABASE_URL`, no API keys. PGLite
* in-memory engine + tempdir SKILL.md + tempdir benchmark JSONL.
*
* .serial.test.ts because the stub installs module-state (the chat
* transport) and the orchestrator walks multi-epoch shared disk state.
*/
import { describe, expect, test, beforeAll, afterAll, beforeEach, afterEach } from 'bun:test';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { PGLiteEngine } from '../../src/core/pglite-engine.ts';
import { resetPgliteState } from '../helpers/reset-pglite.ts';
import { withEnv } from '../helpers/with-env.ts';
import {
__setChatTransportForTests,
type ChatOpts,
type ChatResult,
} from '../../src/core/ai/gateway.ts';
import { runSkillOpt } from '../../src/core/skillopt/orchestrator.ts';
import {
bestPath,
loadHistory,
skillPath,
} from '../../src/core/skillopt/version-store.ts';
import { loadRejectedBuffer } from '../../src/core/skillopt/rejected-buffer.ts';
import type { EditOp } from '../../src/core/skillopt/types.ts';
let engine: PGLiteEngine;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
});
afterAll(async () => {
await engine.disconnect();
});
beforeEach(async () => {
await resetPgliteState(engine);
});
afterEach(() => {
// Belt-and-suspenders: ensure no test leaks a stub transport into the
// next test. Every test path also clears explicitly in its finally block,
// but a failed assertion mid-stub would skip that; this catches the
// skipped-cleanup case.
__setChatTransportForTests(null);
});
// ─── Fixture helpers ────────────────────────────────────────────────────────
const SKILL = 'e2e-loop-skill';
/** A skill with only `## People`. Half the benchmark fails at baseline. */
const SKILL_PEOPLE_ONLY = `---
name: e2e-loop-skill
version: 0.1.0
description: Test skill for E2E SkillOpt loop.
triggers:
- "do the loop task"
brain_first: exempt
---
# E2E Loop Test Skill
When asked, produce a structured output.
## People
List people mentioned.
`;
/** A skill with both sections. Full benchmark passes at baseline. */
const SKILL_BOTH_SECTIONS = `---
name: e2e-loop-skill
version: 0.1.0
description: Test skill for E2E SkillOpt loop.
triggers:
- "do the loop task"
brain_first: exempt
---
# E2E Loop Test Skill
When asked, produce a structured output.
## People
List people mentioned.
## Citations
Cite sources.
`;
/**
* 50 tasks alternating People/Citations rule checks. The benchmark's
* deterministic structure makes baseline scores predictable: with a
* People-only skill, only People-tasks pass → score = 0.5 on any sufficiently
* mixed sample. Split [4,1,5] = 20 train / 5 sel / 25 test (satisfies D17
* floor).
*/
const SAMPLE_BENCHMARK = Array.from({ length: 50 }, (_, i) => {
const n = String(i + 1).padStart(3, '0');
const op = i % 2 === 0 ? 'People' : 'Citations';
return {
task_id: `e2e-${n}`,
task: `Process task ${i + 1}`,
judge: { kind: 'rule' as const, checks: [{ op: 'contains' as const, arg: op }] },
};
});
interface Fixture {
skillsDir: string;
benchmarkPath: string;
cleanup: () => void;
}
/** 50 tasks all checking `contains: Citations` — baseline People-only fails them all. */
const CITATIONS_BENCHMARK = Array.from({ length: 50 }, (_, i) => {
const n = String(i + 1).padStart(3, '0');
return {
task_id: `cit-${n}`,
task: `Process task ${i + 1}`,
judge: { kind: 'rule' as const, checks: [{ op: 'contains' as const, arg: 'Citations' }] },
};
});
/** Held-out set checking `contains: People` — baseline passes, a People-dropping candidate fails. */
const PEOPLE_HELDOUT = Array.from({ length: 6 }, (_, i) => {
const n = String(i + 1).padStart(3, '0');
return {
task_id: `ho-${n}`,
task: `Held-out task ${i + 1}`,
judge: { kind: 'rule' as const, checks: [{ op: 'contains' as const, arg: 'People' }] },
};
});
function setupFixture(
skillBody: string = SKILL_PEOPLE_ONLY,
benchmark: ReadonlyArray<{ task_id: string; task: string; judge: unknown }> = SAMPLE_BENCHMARK,
): Fixture {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'skillopt-loop-e2e-'));
const skillDir = path.join(tmp, SKILL);
fs.mkdirSync(skillDir, { recursive: true });
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), skillBody);
const benchmarkPath = path.join(skillDir, 'skillopt-benchmark.jsonl');
fs.writeFileSync(
benchmarkPath,
benchmark.map((t) => JSON.stringify(t)).join('\n') + '\n',
);
return {
skillsDir: tmp,
benchmarkPath,
cleanup: () => {
try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* ignore */ }
},
};
}
/** Write a held-out JSONL into the fixture's skill dir; return its path. */
function writeHeldOut(fixture: Fixture, tasks: ReadonlyArray<{ task_id: string; task: string; judge: unknown }>): string {
const p = path.join(fixture.skillsDir, SKILL, 'held-out.jsonl');
fs.writeFileSync(p, tasks.map((t) => JSON.stringify(t)).join('\n') + '\n');
return p;
}
// ─── Stub builder ───────────────────────────────────────────────────────────
interface StubOpts {
/** Edit returned by the FAILURE reflect call. Null/undefined → empty edits. */
failureEdit?: EditOp | null;
/** Edit returned by the SUCCESS reflect call. Null/undefined → empty edits. */
successEdit?: EditOp | null;
/**
* Raw text returned by the optimizer (overrides failureEdit + successEdit).
* Used for the malformed-JSON test case.
*/
optimizerRaw?: string;
/**
* Target-agent text emitter. Defaults to "emit text based on which sections
* exist in the skill". Override to simulate broken/idiosyncratic agents.
*/
targetText?: (skillText: string) => string;
/**
* Optional per-call usage override for the budget-exhaustion test. When
* set, every chat call reports this usage (driving cumulative cost up
* fast against a tight cap).
*/
perCallUsage?: { input: number; output: number };
/** Raw body returned for ONE-SHOT REWRITE optimizer calls (optimizerMode test). */
oneShotBody?: string;
/** Counters incremented as the stub observes each call kind (ablation tests). */
stats?: { successReflectCalls: number; oneShotCalls: number };
}
// No trailing period: matches the FAILURE/SUCCESS reflect systems ("...optimizer.")
// AND the one-shot system ("...optimizer in ONE-SHOT REWRITE mode.").
const REFLECT_OPTIMIZER_PREFIX = "You are SkillOpt's optimizer";
const FAILURE_REFLECT_MARKER = 'FAILURE TRAJECTORIES';
const ONE_SHOT_MARKER = 'ONE-SHOT REWRITE';
function defaultTargetText(skillText: string): string {
// Faithful agent: read the skill's body, emit sections that exist there.
// Rule-check `contains: 'People'` passes when the section header is present
// in the rollout's final_text (since the header literal contains 'People').
const parts: string[] = [];
if (skillText.includes('## People')) parts.push('## People\nAlice attended the meeting.');
if (skillText.includes('## Citations')) parts.push('## Citations\nSource: example.com');
return parts.join('\n\n') || 'No structured output produced.';
}
function makeChatResult(
text: string,
model: string,
usage: { input: number; output: number } = { input: 100, output: 20 },
): ChatResult {
return {
text,
blocks: [{ type: 'text', text }],
stopReason: 'end',
usage: {
input_tokens: usage.input,
output_tokens: usage.output,
cache_read_tokens: 0,
cache_creation_tokens: 0,
},
model,
providerId: 'anthropic',
};
}
function installStub(opts: StubOpts): void {
const usage = opts.perCallUsage ?? { input: 100, output: 20 };
__setChatTransportForTests(async (chatOpts: ChatOpts): Promise<ChatResult> => {
const sys = chatOpts.system ?? '';
const isOptimizerCall = sys.startsWith(REFLECT_OPTIMIZER_PREFIX);
if (isOptimizerCall) {
const model = chatOpts.model ?? 'anthropic:claude-opus-4-7';
// ONE-SHOT REWRITE mode returns a raw body, not edits JSON.
if (sys.includes(ONE_SHOT_MARKER)) {
if (opts.stats) opts.stats.oneShotCalls += 1;
return makeChatResult(opts.oneShotBody ?? '', model, usage);
}
if (opts.optimizerRaw !== undefined) {
return makeChatResult(opts.optimizerRaw, model, usage);
}
const isFailureMode = sys.includes(FAILURE_REFLECT_MARKER);
if (!isFailureMode && opts.stats) opts.stats.successReflectCalls += 1;
const edit = isFailureMode ? opts.failureEdit : opts.successEdit;
const text = JSON.stringify({ edits: edit ? [edit] : [] });
return makeChatResult(text, model, usage);
}
// Target-agent rollout.
const model = chatOpts.model ?? 'anthropic:claude-sonnet-4-6';
const fn = opts.targetText ?? defaultTargetText;
return makeChatResult(fn(sys), model, usage);
});
}
function uninstallStub(): void {
__setChatTransportForTests(null);
}
// ─── Common runSkillOpt invocation ──────────────────────────────────────────
interface RunOptsOverride {
maxCostUsd?: number;
epochs?: number;
batchSize?: number;
noMutate?: boolean;
heldOutPath?: string;
optimizerMode?: 'reflect' | 'one-shot-rewrite';
reflectMode?: 'both' | 'failure-only';
disableValidationGate?: boolean;
maxRuntimeMin?: number;
}
async function runOnce(fixture: Fixture, over: RunOptsOverride = {}) {
return runSkillOpt({
engine,
skillName: SKILL,
skillsDir: fixture.skillsDir,
benchmarkPath: fixture.benchmarkPath,
epochs: over.epochs ?? 1,
batchSize: over.batchSize ?? 2,
lr: 4,
lrSchedule: 'constant',
split: [4, 1, 5],
optimizerModel: 'anthropic:claude-opus-4-7',
targetModel: 'anthropic:claude-sonnet-4-6',
judgeModel: 'anthropic:claude-sonnet-4-6',
mode: 'patch',
dryRun: false,
noMutate: over.noMutate ?? false,
allowMutateBundled: true,
bootstrapReviewed: false,
json: true,
maxCostUsd: over.maxCostUsd ?? 100,
maxRuntimeMin: over.maxRuntimeMin ?? 1,
force: true, // bypass dirty-tree (tempdir isn't a git repo)
...(over.heldOutPath ? { heldOutPath: over.heldOutPath } : {}),
...(over.optimizerMode ? { optimizerMode: over.optimizerMode } : {}),
...(over.reflectMode ? { reflectMode: over.reflectMode } : {}),
...(over.disableValidationGate ? { disableValidationGate: over.disableValidationGate } : {}),
});
}
// ─── Cases ──────────────────────────────────────────────────────────────────
describe('skillopt full-loop E2E (happy path + broken cases)', () => {
test('happy path: optimizer proposes a real edit, gate accepts, SKILL.md mutated', async () => {
const fixture = setupFixture(SKILL_PEOPLE_ONLY);
try {
// Optimizer (FAILURE mode) proposes adding a ## Citations section right
// after the ## People heading. After apply, the agent emits both sections
// → every rollout passes → sel score goes from 0.5 (baseline) to 1.0,
// delta = 0.5 ≫ epsilon=0.05 → ACCEPT.
installStub({
failureEdit: {
op: 'add',
anchor: 'People',
content: '## Citations\nCite the source for every claim.',
reason: 'agent failed Citations tasks because no Citations section exists',
},
successEdit: null, // success-mode reflect produces no edits
});
try {
await withEnv({ GBRAIN_AUDIT_DIR: fixture.skillsDir }, async () => {
const result = await runOnce(fixture);
// Outcome contract: accepted + mutated.
expect(result.outcome).toBe('accepted');
expect(result.mutatedSkillFile).toBe(true);
// SKILL.md on disk now has BOTH sections.
const finalSkill = fs.readFileSync(skillPath(fixture.skillsDir, SKILL), 'utf8');
expect(finalSkill).toContain('## People');
expect(finalSkill).toContain('## Citations');
// Frontmatter preserved (D5: edits never touch frontmatter).
expect(finalSkill).toContain('name: e2e-loop-skill');
expect(finalSkill).toContain('brain_first: exempt');
// best.md mirrors the on-disk SKILL.md content.
expect(fs.readFileSync(bestPath(fixture.skillsDir, SKILL), 'utf8')).toBe(finalSkill);
// History has exactly one committed row with the right shape.
const history = loadHistory(fixture.skillsDir, SKILL);
const committed = history.filter((r) => r.status === 'committed');
expect(committed).toHaveLength(1);
expect(committed[0]!.version_n).toBe(1);
expect(committed[0]!.delta).toBeGreaterThan(0.05); // > epsilon
expect(committed[0]!.sel_score).toBeGreaterThan(committed[0]!.delta); // monotone
// The committed row records the actual edit applied (not just an empty proposal).
expect(committed[0]!.edits).toHaveLength(1);
expect(committed[0]!.edits[0]).toMatchObject({ op: 'add', anchor: 'People' });
// Receipt sel_score reflects the accepted candidate's score.
expect(result.receipt.best_sel_score).toBeGreaterThan(0.9);
expect(result.receipt.outcome).toBe('accepted');
expect(result.receipt.epochs_completed).toBe(1);
});
} finally {
uninstallStub();
}
} finally {
fixture.cleanup();
}
});
test('broken: below-baseline regression edit (gate rejects, SKILL.md unchanged)', async () => {
// Start with a skill that already scores 1.0. The optimizer (in SUCCESS
// mode — failures=[] since baseline is perfect) proposes a destructive
// edit that removes the ## People section. The candidate's sel score
// collapses to 0.5; the gate rejects with reason=below_baseline; the
// on-disk SKILL.md MUST stay byte-identical to the baseline.
const fixture = setupFixture(SKILL_BOTH_SECTIONS);
try {
installStub({
failureEdit: null,
successEdit: {
op: 'delete',
target: '## People\nList people mentioned.\n',
reason: 'mistakenly thinks the People section is redundant',
},
});
try {
await withEnv({ GBRAIN_AUDIT_DIR: fixture.skillsDir }, async () => {
const result = await runOnce(fixture);
// Outcome contract: no acceptance + no mutation.
expect(result.outcome).toBe('no_improvement');
expect(result.mutatedSkillFile).toBe(false);
// SKILL.md on disk is byte-identical to baseline.
expect(fs.readFileSync(skillPath(fixture.skillsDir, SKILL), 'utf8'))
.toBe(SKILL_BOTH_SECTIONS);
// History is empty (no committed rows; rejected edits don't enter history).
const history = loadHistory(fixture.skillsDir, SKILL);
expect(history.filter((r) => r.status === 'committed')).toHaveLength(0);
// The destructive edit landed in the rejected-edits buffer for
// anti-bias context on future runs (the optimizer learns).
const rejected = loadRejectedBuffer(fixture.skillsDir, SKILL);
expect(rejected.length).toBeGreaterThan(0);
expect(rejected.some((e) => e.reason.startsWith('validation_gate'))).toBe(true);
// The recorded edit shape matches the destructive proposal so the
// optimizer's anti-bias prompt sees the actual edit, not a stub.
expect(rejected.some((e) =>
e.edits.some((edit) => edit.op === 'delete' && (edit as { target: string }).target.includes('## People')),
)).toBe(true);
});
} finally {
uninstallStub();
}
} finally {
fixture.cleanup();
}
});
test('broken: malformed reflect JSON (no edits parsed, no acceptance)', async () => {
// The optimizer returns syntactically broken JSON. The reflect module's
// forgiving parser yields zero valid edits; applyEditBatch sees an empty
// batch; the orchestrator hits the "no_edits_applied" branch; the sel
// gate is never invoked. SKILL.md stays untouched. Critically: the run
// does NOT crash on malformed optimizer output (graceful degradation).
const fixture = setupFixture(SKILL_PEOPLE_ONLY);
try {
installStub({
// Adversarial: looks like JSON but isn't. Different broken shapes
// hit different fallback paths in tryExtractEdits.
optimizerRaw: '{"edits": [BROKEN, no quotes, trailing comma,]',
});
try {
await withEnv({ GBRAIN_AUDIT_DIR: fixture.skillsDir }, async () => {
const result = await runOnce(fixture);
expect(result.outcome).toBe('no_improvement');
expect(result.mutatedSkillFile).toBe(false);
expect(fs.readFileSync(skillPath(fixture.skillsDir, SKILL), 'utf8'))
.toBe(SKILL_PEOPLE_ONLY);
expect(loadHistory(fixture.skillsDir, SKILL).filter((r) => r.status === 'committed'))
.toHaveLength(0);
});
} finally {
uninstallStub();
}
} finally {
fixture.cleanup();
}
});
test('broken: anchor-not-found edit (apply rejects, sel gate skipped)', async () => {
// The optimizer proposes a structurally valid edit pointing at a heading
// that doesn't exist in the skill. applyEditBatch returns all-rejected;
// the orchestrator's all-rejected branch fires (logs no_edits_applied,
// pushes to rejected-buffer, skips the sel gate). The skill stays
// unchanged AND the bogus anchor lands in the rejected-buffer with
// reason 'apply_failed' (separate from gate-rejected entries).
const fixture = setupFixture(SKILL_PEOPLE_ONLY);
try {
installStub({
failureEdit: {
op: 'add',
anchor: 'NonExistentHeading',
content: 'Some content',
reason: 'optimizer hallucinated a heading',
},
});
try {
await withEnv({ GBRAIN_AUDIT_DIR: fixture.skillsDir }, async () => {
const result = await runOnce(fixture);
expect(result.outcome).toBe('no_improvement');
expect(result.mutatedSkillFile).toBe(false);
expect(fs.readFileSync(skillPath(fixture.skillsDir, SKILL), 'utf8'))
.toBe(SKILL_PEOPLE_ONLY);
// Rejected-buffer should carry an apply_failed entry (the failed
// anchor lookup is recorded so the optimizer doesn't re-propose).
const rejected = loadRejectedBuffer(fixture.skillsDir, SKILL);
expect(rejected.length).toBeGreaterThan(0);
expect(rejected.some((e) => e.reason === 'apply_failed')).toBe(true);
});
} finally {
uninstallStub();
}
} finally {
fixture.cleanup();
}
});
test('broken: budget exhausted mid-run (aborts cleanly, no half-committed state)', async () => {
// Cap is just under the preflight estimate so the run starts (preflight
// refusal would prevent us from observing BudgetExhausted mid-loop), then
// trips on the cumulative spend during the loop. Outcome=aborted is the
// contract; the load-bearing assertion is that NO pending or committed
// history rows survive (the abort path must not leave the skill in a
// half-mutated state).
const fixture = setupFixture(SKILL_PEOPLE_ONLY);
try {
installStub({
failureEdit: {
op: 'add',
anchor: 'People',
content: '## Citations\nCite.',
reason: 'mid-budget edit',
},
// Drive per-call cost up so the cumulative spend trips the cap fast.
perCallUsage: { input: 50_000, output: 5_000 },
});
try {
await withEnv({ GBRAIN_AUDIT_DIR: fixture.skillsDir }, async () => {
// The exact cap is calibrated to (a) survive the preflight check
// (preflight refuses with a CostCapExceeded error if its estimate
// exceeds the cap), but (b) trip mid-loop when real per-call
// usage from the stub accumulates. Preflight estimates assume
// small per-call usage; the stub inflates per-call usage so we
// exceed the cap before the loop completes.
let result: Awaited<ReturnType<typeof runOnce>>;
try {
result = await runOnce(fixture, { maxCostUsd: 5.0 });
} catch (err) {
// Acceptable: preflight may refuse before the loop starts if its
// estimator now overshoots. In that case the contract becomes
// "no mutation happened" — assert that directly via filesystem.
const msg = err instanceof Error ? err.message : String(err);
expect(msg).toMatch(/cost|budget/i);
expect(fs.readFileSync(skillPath(fixture.skillsDir, SKILL), 'utf8'))
.toBe(SKILL_PEOPLE_ONLY);
return;
}
// Reaching the loop: budget exhausted is the expected outcome. Other
// non-acceptance outcomes (no_improvement, errored) are also fine
// — the load-bearing assertion is no half-committed state.
expect(['aborted', 'no_improvement', 'errored']).toContain(result.outcome);
// No PENDING rows: the v0.42 D8 two-phase commit insists every
// pending row is either committed or reverted; an abort path that
// leaves a pending row would corrupt resume.
const history = loadHistory(fixture.skillsDir, SKILL);
expect(history.filter((r) => r.status === 'pending')).toHaveLength(0);
// If outcome is aborted, MUST NOT mutate.
if (result.outcome === 'aborted') {
expect(result.mutatedSkillFile).toBe(false);
expect(fs.readFileSync(skillPath(fixture.skillsDir, SKILL), 'utf8'))
.toBe(SKILL_PEOPLE_ONLY);
}
});
} finally {
uninstallStub();
}
} finally {
fixture.cleanup();
}
});
test('converged skill: re-running on perfect baseline yields no_improvement (no double-commit)', async () => {
// Start from an already-perfect skill (baseline score = 1.0). The forward
// gate finds zero failures. The reflect failure-mode path is never
// invoked (failures=[]); only success-mode runs. The success-mode stub
// returns empty edits. The loop converges with outcome=no_improvement
// and the on-disk skill stays byte-identical. This proves the optimizer
// doesn't pointlessly mutate a converged skill — the v1 "convergence"
// path that protects against thrash on a well-tuned starting point.
const fixture = setupFixture(SKILL_BOTH_SECTIONS);
try {
installStub({
failureEdit: null, // wouldn't be called anyway — baseline has no failures
successEdit: null, // success-mode stub returns empty edits
});
try {
await withEnv({ GBRAIN_AUDIT_DIR: fixture.skillsDir }, async () => {
const result = await runOnce(fixture);
expect(result.outcome).toBe('no_improvement');
expect(result.mutatedSkillFile).toBe(false);
expect(fs.readFileSync(skillPath(fixture.skillsDir, SKILL), 'utf8'))
.toBe(SKILL_BOTH_SECTIONS);
// Receipt baseline IS the best score (no improvement to report).
expect(result.receipt.best_sel_score).toBeGreaterThan(0.9);
// History is empty.
expect(loadHistory(fixture.skillsDir, SKILL).filter((r) => r.status === 'committed'))
.toHaveLength(0);
});
} finally {
uninstallStub();
}
} finally {
fixture.cleanup();
}
});
test('idempotent re-run: accept once, run again, second run sees new baseline + does not re-mutate', async () => {
// The cathedral test: drive the loop twice in sequence on the same
// fixture. Run 1 accepts the add-Citations edit (skill improves from
// People-only to both sections). Run 2 starts from the now-improved
// skill, sees baseline=1.0, finds no failures, returns no_improvement.
// SKILL.md stays at v1; history still has exactly one committed row.
// This proves the optimizer is "stable at the fixed point" — the
// critical property of an iterative optimizer.
const fixture = setupFixture(SKILL_PEOPLE_ONLY);
try {
// Same stub config across both runs: failure-mode proposes the
// add-Citations edit. After run 1 accepts it, run 2's forward gate
// sees no failures → failure-reflect never fires → no edits proposed.
const stubConfig = {
failureEdit: {
op: 'add' as const,
anchor: 'People',
content: '## Citations\nCite the source.',
reason: 'baseline missing Citations section',
},
successEdit: null,
};
// Run 1: accept.
installStub(stubConfig);
try {
await withEnv({ GBRAIN_AUDIT_DIR: fixture.skillsDir }, async () => {
const result1 = await runOnce(fixture);
expect(result1.outcome).toBe('accepted');
expect(result1.mutatedSkillFile).toBe(true);
});
} finally {
uninstallStub();
}
const afterRun1 = fs.readFileSync(skillPath(fixture.skillsDir, SKILL), 'utf8');
const historyAfterRun1 = loadHistory(fixture.skillsDir, SKILL);
expect(historyAfterRun1.filter((r) => r.status === 'committed')).toHaveLength(1);
// Run 2: same stub, but loop should observe the improved baseline and
// converge without further mutation.
installStub(stubConfig);
try {
await withEnv({ GBRAIN_AUDIT_DIR: fixture.skillsDir }, async () => {
const result2 = await runOnce(fixture);
expect(result2.outcome).toBe('no_improvement');
expect(result2.mutatedSkillFile).toBe(false);
});
} finally {
uninstallStub();
}
// SKILL.md byte-identical to its state after run 1.
expect(fs.readFileSync(skillPath(fixture.skillsDir, SKILL), 'utf8')).toBe(afterRun1);
// History still has exactly one committed row (no double-commit).
const historyAfterRun2 = loadHistory(fixture.skillsDir, SKILL);
expect(historyAfterRun2.filter((r) => r.status === 'committed')).toHaveLength(1);
// version_n unchanged at 1.
expect(historyAfterRun2.filter((r) => r.status === 'committed')[0]!.version_n).toBe(1);
} finally {
fixture.cleanup();
}
});
});
// ─── T3: held-out gate + ablation opts + no-DB-pollution ─────────────────────
describe('skillopt T3 — F11 held-out gate, ablation opts, no-DB-pollution', () => {
test('F11 held-out BLOCKS: candidate passes D_sel but regresses held-out → no commit', async () => {
// baseline People-only fails the Citations benchmark; the failure edit
// REPLACES People with Citations → candidate passes D_sel (Citations) but
// tanks the held-out (People) → held-out gate refuses the commit.
const fixture = setupFixture(SKILL_PEOPLE_ONLY, CITATIONS_BENCHMARK);
const heldOutPath = writeHeldOut(fixture, PEOPLE_HELDOUT);
try {
installStub({
failureEdit: {
op: 'replace',
target: '## People\nList people mentioned.',
replacement: '## Citations\nCite the source.',
reason: 'swap People for Citations to pass the benchmark',
},
successEdit: null,
});
try {
await withEnv({ GBRAIN_AUDIT_DIR: fixture.skillsDir }, async () => {
const result = await runOnce(fixture, { heldOutPath });
// Held-out gate blocked the promotion.
expect(result.outcome).toBe('no_improvement');
// SKILL.md unchanged: still People-only, never swapped to Citations.
const skill = fs.readFileSync(skillPath(fixture.skillsDir, SKILL), 'utf8');
expect(skill).toContain('## People');
expect(skill).not.toContain('## Citations');
// No committed history row.
expect(loadHistory(fixture.skillsDir, SKILL).filter((r) => r.status === 'committed')).toHaveLength(0);
});
} finally { uninstallStub(); }
} finally { fixture.cleanup(); }
});
test('F11 held-out ALLOWS: candidate improves D_sel AND holds held-out → commit', async () => {
// ADD Citations (keep People): passes D_sel (Citations) and keeps held-out
// (People) at baseline → held-out gate allows → commit.
const fixture = setupFixture(SKILL_PEOPLE_ONLY, CITATIONS_BENCHMARK);
const heldOutPath = writeHeldOut(fixture, PEOPLE_HELDOUT);
try {
installStub({
failureEdit: { op: 'add', anchor: 'People', content: '## Citations\nCite the source.', reason: 'add Citations' },
successEdit: null,
});
try {
await withEnv({ GBRAIN_AUDIT_DIR: fixture.skillsDir }, async () => {
const result = await runOnce(fixture, { heldOutPath });
expect(result.outcome).toBe('accepted');
const skill = fs.readFileSync(skillPath(fixture.skillsDir, SKILL), 'utf8');
expect(skill).toContain('## People');
expect(skill).toContain('## Citations');
expect(loadHistory(fixture.skillsDir, SKILL).filter((r) => r.status === 'committed')).toHaveLength(1);
});
} finally { uninstallStub(); }
} finally { fixture.cleanup(); }
});
test('--no-mutate writes proposed.md (best.md), leaves SKILL.md untouched', async () => {
const fixture = setupFixture(SKILL_PEOPLE_ONLY, CITATIONS_BENCHMARK);
try {
installStub({
failureEdit: { op: 'add', anchor: 'People', content: '## Citations\nCite the source.', reason: 'add Citations' },
successEdit: null,
});
try {
await withEnv({ GBRAIN_AUDIT_DIR: fixture.skillsDir }, async () => {
const result = await runOnce(fixture, { noMutate: true });
expect(result.outcome).toBe('accepted');
expect(result.mutatedSkillFile).toBe(false);
expect(result.proposedPath).toBeDefined();
// proposed.md (best.md) exists and carries the improvement.
expect(fs.existsSync(result.proposedPath!)).toBe(true);
expect(fs.readFileSync(result.proposedPath!, 'utf8')).toContain('## Citations');
// SKILL.md on disk is UNCHANGED (still People-only).
const skill = fs.readFileSync(skillPath(fixture.skillsDir, SKILL), 'utf8');
expect(skill).not.toContain('## Citations');
});
} finally { uninstallStub(); }
} finally { fixture.cleanup(); }
});
test('optimizerMode one-shot-rewrite: single rewrite, no epoch loop, receipt records mode', async () => {
const fixture = setupFixture(SKILL_PEOPLE_ONLY, CITATIONS_BENCHMARK);
const stats = { successReflectCalls: 0, oneShotCalls: 0 };
try {
installStub({
stats,
// Body-only rewrite (frontmatter re-attached by the orchestrator).
oneShotBody: '# E2E Loop Test Skill\n\nProduce a structured output.\n\n## People\nList people.\n\n## Citations\nCite the source.\n',
});
try {
await withEnv({ GBRAIN_AUDIT_DIR: fixture.skillsDir }, async () => {
const result = await runOnce(fixture, { optimizerMode: 'one-shot-rewrite' });
expect(result.outcome).toBe('accepted');
expect(result.receipt.optimizer_mode).toBe('one-shot-rewrite');
// Exactly ONE optimizer rewrite call — no epoch loop.
expect(stats.oneShotCalls).toBe(1);
expect(result.receipt.total_steps).toBe(1);
const skill = fs.readFileSync(skillPath(fixture.skillsDir, SKILL), 'utf8');
expect(skill).toContain('## Citations');
// Frontmatter preserved (D5).
expect(skill).toContain('name: e2e-loop-skill');
});
} finally { uninstallStub(); }
} finally { fixture.cleanup(); }
});
test('reflectMode failure-only SKIPS the success reflect call; default fires it', async () => {
// Mixed benchmark → baseline has both successes (People tasks) and failures
// (Citations tasks), so default mode WOULD fire the success reflect.
const failOnly = { successReflectCalls: 0, oneShotCalls: 0 };
const fixtureA = setupFixture(SKILL_PEOPLE_ONLY, SAMPLE_BENCHMARK);
try {
installStub({
stats: failOnly,
failureEdit: { op: 'add', anchor: 'People', content: '## Citations\nCite.', reason: 'add' },
successEdit: { op: 'add', anchor: 'People', content: '<!-- success note -->', reason: 'note' },
});
try {
await withEnv({ GBRAIN_AUDIT_DIR: fixtureA.skillsDir }, async () => {
await runOnce(fixtureA, { reflectMode: 'failure-only' });
expect(failOnly.successReflectCalls).toBe(0);
});
} finally { uninstallStub(); }
} finally { fixtureA.cleanup(); }
const both = { successReflectCalls: 0, oneShotCalls: 0 };
const fixtureB = setupFixture(SKILL_PEOPLE_ONLY, SAMPLE_BENCHMARK);
try {
installStub({
stats: both,
failureEdit: { op: 'add', anchor: 'People', content: '## Citations\nCite.', reason: 'add' },
successEdit: { op: 'add', anchor: 'People', content: '<!-- success note -->', reason: 'note' },
});
try {
await withEnv({ GBRAIN_AUDIT_DIR: fixtureB.skillsDir }, async () => {
await runOnce(fixtureB);
expect(both.successReflectCalls).toBeGreaterThan(0);
});
} finally { uninstallStub(); }
} finally { fixtureB.cleanup(); }
});
test('disableValidationGate greedy-accepts a no-improvement edit the gate would reject', async () => {
// BOTH_SECTIONS already scores 1.0; a benign success edit yields delta 0,
// which the D12 gate rejects — unless disableValidationGate greedy-accepts.
const benignEdit = { op: 'add' as const, anchor: 'Citations', content: 'Extra note.', reason: 'benign' };
const fixtureGated = setupFixture(SKILL_BOTH_SECTIONS, SAMPLE_BENCHMARK);
try {
installStub({ successEdit: benignEdit, failureEdit: null });
try {
await withEnv({ GBRAIN_AUDIT_DIR: fixtureGated.skillsDir }, async () => {
const result = await runOnce(fixtureGated);
expect(result.outcome).toBe('no_improvement'); // gate rejected
});
} finally { uninstallStub(); }
} finally { fixtureGated.cleanup(); }
const fixtureGreedy = setupFixture(SKILL_BOTH_SECTIONS, SAMPLE_BENCHMARK);
try {
installStub({ successEdit: benignEdit, failureEdit: null });
try {
await withEnv({ GBRAIN_AUDIT_DIR: fixtureGreedy.skillsDir }, async () => {
const result = await runOnce(fixtureGreedy, { disableValidationGate: true });
expect(result.outcome).toBe('accepted'); // greedy
expect(result.receipt.validation_gate_disabled).toBe(true);
});
} finally { uninstallStub(); }
} finally { fixtureGreedy.cleanup(); }
});
test('D2 no-DB-pollution: subagent_messages count unchanged across a full run', async () => {
const fixture = setupFixture(SKILL_PEOPLE_ONLY, CITATIONS_BENCHMARK);
const countMessages = async (): Promise<number> => {
const r = await engine.executeRaw('SELECT COUNT(*) AS c FROM subagent_messages', []);
const rows = Array.isArray(r) ? r : ((r as { rows?: unknown[] }).rows ?? []);
return Number((rows[0] as { c?: number | string })?.c ?? 0);
};
try {
const before = await countMessages();
installStub({
failureEdit: { op: 'add', anchor: 'People', content: '## Citations\nCite the source.', reason: 'add' },
successEdit: null,
});
try {
await withEnv({ GBRAIN_AUDIT_DIR: fixture.skillsDir }, async () => {
await runOnce(fixture);
});
} finally { uninstallStub(); }
const after = await countMessages();
// Rollouts use gateway.toolLoop with no-op persistence (D2) → zero rows written.
expect(after).toBe(before);
} finally { fixture.cleanup(); }
});
});
// ─── T3 (review follow-ups): maxRuntimeMin abort + receipt honesty ───────────
describe('skillopt T3 — runtime deadline + receipt score honesty', () => {
test('maxRuntimeMin deadline aborts cleanly with no commit', async () => {
const fixture = setupFixture(SKILL_PEOPLE_ONLY, CITATIONS_BENCHMARK);
try {
installStub({
failureEdit: { op: 'add', anchor: 'People', content: '## Citations\nCite the source.', reason: 'add' },
successEdit: null,
});
try {
await withEnv({ GBRAIN_AUDIT_DIR: fixture.skillsDir }, async () => {
// maxRuntimeMin:0 → deadline == run start; the first step's deadline
// check fires after the baseline eval has already elapsed → abort.
const result = await runOnce(fixture, { maxRuntimeMin: 0 });
expect(result.outcome).toBe('aborted');
// No commit: SKILL.md unchanged, no committed history row.
const skill = fs.readFileSync(skillPath(fixture.skillsDir, SKILL), 'utf8');
expect(skill).not.toContain('## Citations');
expect(loadHistory(fixture.skillsDir, SKILL).filter((r) => r.status === 'committed')).toHaveLength(0);
});
} finally { uninstallStub(); }
} finally { fixture.cleanup(); }
});
test('receipt records the REAL baseline_sel_score + final-test scores (regression: was hardcoded 0)', async () => {
// BOTH_SECTIONS already scores ~1.0 on the alternating benchmark, so a real
// baseline read is ~1.0 — a hardcoded-0 receipt would fail this immediately.
const fixture = setupFixture(SKILL_BOTH_SECTIONS, SAMPLE_BENCHMARK);
try {
installStub({ successEdit: null, failureEdit: null }); // baseline already perfect; no edits
try {
await withEnv({ GBRAIN_AUDIT_DIR: fixture.skillsDir }, async () => {
const r = (await runOnce(fixture)).receipt;
// Real baseline, not the old hardcoded 0.
expect(r.baseline_sel_score).toBeGreaterThan(0.9);
// Final-test eval populated both test scores (D_test non-empty under 4:1:5).
expect(typeof r.test_score).toBe('number');
expect(typeof r.baseline_test_score).toBe('number');
expect(r.baseline_test_score!).toBeGreaterThan(0.9);
});
} finally { uninstallStub(); }
} finally { fixture.cleanup(); }
});
});
describe('skillopt T3 — held-out independence guard', () => {
test('held-out sharing task_ids with the benchmark is rejected (gaming defense)', async () => {
const fixture = setupFixture(SKILL_PEOPLE_ONLY, CITATIONS_BENCHMARK);
// Held-out file reuses benchmark task_ids (cit-001..006) → must be rejected
// before any optimization, since an overlapping held-out can't catch overfit.
const heldOutPath = writeHeldOut(fixture, CITATIONS_BENCHMARK.slice(0, 6));
try {
installStub({
failureEdit: { op: 'add', anchor: 'People', content: '## Citations\nCite.', reason: 'add' },
successEdit: null,
});
try {
await withEnv({ GBRAIN_AUDIT_DIR: fixture.skillsDir }, async () => {
await expect(runOnce(fixture, { heldOutPath })).rejects.toThrow(/independent|shares .* task_id/i);
});
} finally { uninstallStub(); }
} finally { fixture.cleanup(); }
});
});