mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-30 11:22:34 +00:00
* 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 the284c50a4band-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>
298 lines
15 KiB
Markdown
298 lines
15 KiB
Markdown
# Auto-improve a skill with `gbrain skillopt`
|
||
|
||
You have a `SKILL.md`. Sometimes the agent following it does a great job, sometimes
|
||
it forgets a step or pads the output. This tutorial takes you from that skill to a
|
||
measurably better version of it, in one session, without you hand-editing the
|
||
prose. By the end you'll have written your first benchmark, watched the optimizer
|
||
propose and test edits, and accepted an improvement that actually scored higher.
|
||
|
||
Time: ~20 minutes. Cost: ~$1 in API calls for the worked example.
|
||
|
||
Based on [SkillOpt](https://arxiv.org/abs/2605.23904) (Microsoft Research, May 2026).
|
||
|
||
## The mental model (two sentences)
|
||
|
||
Your `SKILL.md` is the trainable parameter; the agent that reads it never changes.
|
||
SkillOpt runs the agent against a benchmark of realistic tasks, proposes specific
|
||
edits to the skill body, re-tests, and keeps a change **only when it measurably
|
||
beats the current version** on a held-out slice.
|
||
|
||
That's the whole idea. The benchmark is how "better" gets defined — which is why
|
||
writing it is the one part you can't skip. Everything else is mechanical.
|
||
|
||
## The easiest path: generate a starter, then strengthen it
|
||
|
||
You don't start from a blank file. One command reads the SKILL.md and writes a
|
||
full starter benchmark for you:
|
||
|
||
```bash
|
||
gbrain skillopt meeting-prep --bootstrap-from-skill
|
||
```
|
||
|
||
It infers what the skill produces, writes ~15 tasks (each with rule judges) to
|
||
`skills/meeting-prep/skillopt-benchmark.jsonl`, and appends a
|
||
`# BOOTSTRAP_PENDING_REVIEW` sentinel so nothing runs until a human has looked.
|
||
Then you **review and strengthen the judges** (the generated checks are weak
|
||
drafts), delete the sentinel line, and run:
|
||
|
||
```bash
|
||
gbrain skillopt meeting-prep --bootstrap-reviewed --split 1:1:1
|
||
```
|
||
|
||
If you run an agent over this brain (OpenClaw, Claude Code, Cursor, any MCP client
|
||
with the gbrain skills installed), it does this for you: just say "improve my
|
||
meeting-prep skill." It runs `--bootstrap-from-skill`, strengthens the judges,
|
||
dry-runs for cost, runs the optimizer, and reports the diff + score delta back.
|
||
You keep or discard.
|
||
|
||
**Read the rest of this tutorial to understand what that command produces** — the
|
||
benchmark format, how to strengthen a draft (or write one by hand), how to read
|
||
the outcome, and where the output lands.
|
||
|
||
## What you'll need
|
||
|
||
- `gbrain` installed and a brain initialized (`gbrain --version` works).
|
||
- One embedding/chat provider configured. SkillOpt makes real LLM calls.
|
||
`gbrain models doctor` should show at least one reachable chat model.
|
||
- A skill you want to improve, living at `skills/<name>/SKILL.md`. This tutorial
|
||
uses a skill called `meeting-prep` — substitute your own name everywhere.
|
||
- A clean git working tree for that skill file (SkillOpt refuses to run over
|
||
uncommitted changes so it can never clobber your edits; `--force` overrides).
|
||
|
||
If you don't have a skill yet, scaffold one first:
|
||
|
||
```bash
|
||
gbrain skillify scaffold meeting-prep
|
||
```
|
||
|
||
## Step 1: Get a benchmark — generated or hand-written
|
||
|
||
A benchmark is a `.jsonl` file — **one JSON object per line** — where each line is
|
||
a task plus a way to score the agent's answer. It's the crux: the benchmark IS
|
||
your definition of "better."
|
||
|
||
**The recommended way is to generate a starter** (the section above):
|
||
`gbrain skillopt meeting-prep --bootstrap-from-skill` writes the file for you, then
|
||
you strengthen the judges. The format below is exactly what it produces, so this
|
||
section doubles as your guide to reviewing and sharpening a generated draft.
|
||
|
||
**To follow this tutorial verbatim** (or to hand-curate from scratch), paste this
|
||
complete 15-task starter. It's deliberately generic — once you've seen the loop
|
||
work, **replace these tasks with your skill's real cases** (that's Step 6):
|
||
|
||
```bash
|
||
cat > skills/meeting-prep/skillopt-benchmark.jsonl <<'EOF'
|
||
{"task_id":"mp-001","task":"Prep me for a 1:1 with a direct report I haven't met with in 3 weeks.","judge":{"kind":"rule","checks":[{"op":"max_chars","arg":1800},{"op":"contains","arg":"agenda"},{"op":"contains","arg":"follow-up"}]}}
|
||
{"task_id":"mp-002","task":"Prep me for a first sales call with a company I know nothing about.","judge":{"kind":"rule","checks":[{"op":"max_chars","arg":1800},{"op":"contains","arg":"company"},{"op":"min_citations","arg":1}]}}
|
||
{"task_id":"mp-003","task":"Prep me for a board meeting where I present the quarterly numbers.","judge":{"kind":"rule","checks":[{"op":"max_chars","arg":1800},{"op":"contains","arg":"metric"}]}}
|
||
{"task_id":"mp-004","task":"Prep me for a performance review I'm giving to an underperformer.","judge":{"kind":"rule","checks":[{"op":"max_chars","arg":1800},{"op":"contains","arg":"example"}]}}
|
||
{"task_id":"mp-005","task":"Prep me for a candidate interview for a senior backend role.","judge":{"kind":"rule","checks":[{"op":"max_chars","arg":1800},{"op":"contains","arg":"question"}]}}
|
||
{"task_id":"mp-006","task":"Prep me for a vendor renewal negotiation where I want a discount.","judge":{"kind":"rule","checks":[{"op":"max_chars","arg":1800},{"op":"contains","arg":"leverage"}]}}
|
||
{"task_id":"mp-007","task":"Prep me for a kickoff with a new cross-functional project team.","judge":{"kind":"rule","checks":[{"op":"max_chars","arg":1800},{"op":"contains","arg":"goal"},{"op":"contains","arg":"owner"}]}}
|
||
{"task_id":"mp-008","task":"Prep me for a difficult conversation about a missed deadline.","judge":{"kind":"rule","checks":[{"op":"max_chars","arg":1800},{"op":"contains","arg":"impact"}]}}
|
||
{"task_id":"mp-009","task":"Prep me for an investor update call after a flat quarter.","judge":{"kind":"rule","checks":[{"op":"max_chars","arg":1800},{"op":"contains","arg":"metric"},{"op":"min_citations","arg":1}]}}
|
||
{"task_id":"mp-010","task":"Prep me for a skip-level with someone two reports below me.","judge":{"kind":"rule","checks":[{"op":"max_chars","arg":1800},{"op":"contains","arg":"question"}]}}
|
||
{"task_id":"mp-011","task":"Prep me for a customer escalation call after an outage.","judge":{"kind":"rule","checks":[{"op":"max_chars","arg":1800},{"op":"contains","arg":"timeline"}]}}
|
||
{"task_id":"mp-012","task":"Prep me for a partnership exploration call with a competitor-adjacent company.","judge":{"kind":"rule","checks":[{"op":"max_chars","arg":1800},{"op":"contains","arg":"company"},{"op":"min_citations","arg":1}]}}
|
||
{"task_id":"mp-013","task":"Prep me for a sprint retro where morale has been low.","judge":{"kind":"rule","checks":[{"op":"max_chars","arg":1800},{"op":"contains","arg":"action"}]}}
|
||
{"task_id":"mp-014","task":"Prep me for a salary negotiation a report initiated.","judge":{"kind":"rule","checks":[{"op":"max_chars","arg":1800},{"op":"contains","arg":"market"}]}}
|
||
{"task_id":"mp-015","task":"Prep me for an all-hands where I announce a reorg.","judge":{"kind":"rule","checks":[{"op":"max_chars","arg":1800},{"op":"contains","arg":"why"}]}}
|
||
EOF
|
||
```
|
||
|
||
Each line has three fields:
|
||
|
||
- `task_id` — a unique label. Anything; you'll see it in the audit trail.
|
||
- `task` — the prompt the agent gets, exactly as a user would phrase it.
|
||
- `judge` — how the answer is scored. `kind: "rule"` is deterministic and **free**
|
||
(no LLM call): it runs a list of `checks`, and the task's score is the fraction
|
||
that pass.
|
||
|
||
The rule checks you can use:
|
||
|
||
| `op` | `arg` | Passes when the agent's answer… |
|
||
|---|---|---|
|
||
| `contains` | string | includes that substring |
|
||
| `regex` | string | matches that regex (multiline) |
|
||
| `section_present` | heading text | has a markdown heading with that text |
|
||
| `max_chars` | number | is at most that many characters (punishes padding) |
|
||
| `min_citations` | number | has at least N citations (markdown links, `wiki/…` refs, `[1]` footnotes) |
|
||
| `tool_called` | tool name | the agent called that tool during the rollout |
|
||
| `tool_not_called` | tool name | the agent did NOT call that tool |
|
||
|
||
Rule judges are the right place to start. They're free, deterministic, and they
|
||
force you to say concretely what a good answer looks like. (`judge.kind` can also
|
||
be `"llm"` with a rubric, or `"qrels"` for retrieval tasks — see the
|
||
[reference guide](../guides/skillopt.md) once you outgrow rules.)
|
||
|
||
### The one gotcha: how many tasks you need
|
||
|
||
SkillOpt splits your benchmark three ways — **train** (propose edits against),
|
||
**sel** (the held-out gate that decides accept/reject), and **test** (final
|
||
score). The sel slice must have **at least 5 tasks** or the run refuses, so noise
|
||
can't masquerade as improvement.
|
||
|
||
The default split is `4:1:5`, which means sel is 1/10th of your tasks — so the
|
||
default needs **~50 tasks** before it'll run. That's too many for a first
|
||
benchmark, which is why every command below passes `--split 1:1:1`: with the
|
||
15-task starter that's a clean **5 train / 5 sel / 5 test**, and sel hits the
|
||
floor exactly.
|
||
|
||
```bash
|
||
# 15 tasks + --split 1:1:1 → 5 train / 5 sel / 5 test
|
||
gbrain skillopt meeting-prep --split 1:1:1
|
||
```
|
||
|
||
If you ever see `D_sel has N task(s) after split (need >=5)`, you either added
|
||
fewer than 15 tasks or used a split whose middle number is too small a share.
|
||
`--split 1:1:1` on 15+ tasks is the simplest thing that works.
|
||
|
||
> When you swap in your own tasks (Step 6), keep at least 15 and cover the boring
|
||
> middle, not just the edge cases. The benchmark IS your definition of quality;
|
||
> a thin benchmark optimizes for a thin definition.
|
||
|
||
## Step 2: Preview the cost (dry run)
|
||
|
||
Before spending anything, see what the run will cost:
|
||
|
||
```bash
|
||
gbrain skillopt meeting-prep --split 1:1:1 --dry-run
|
||
```
|
||
|
||
This makes **zero LLM calls** — it just prints the plan and the cost estimate.
|
||
A ~15-task benchmark with defaults runs around $0.70–$1.00. The preflight refuses
|
||
to start a real run whose estimate exceeds `--max-cost-usd` (default $5.00), so
|
||
you can't get surprise-billed mid-run.
|
||
|
||
> `--dry-run` exits with code **2** ("aborted"). That's the convention for "did
|
||
> not run the optimization," not a failure. The cost line is what you came for.
|
||
|
||
## Step 3: Run it for real
|
||
|
||
```bash
|
||
gbrain skillopt meeting-prep --split 1:1:1
|
||
```
|
||
|
||
You'll watch it work: a baseline eval to set the bar, then per-step forward passes
|
||
(run the skill), backward passes (propose edits), and a validation gate that
|
||
runs each sel task's judge 3 times and takes the median — accepting only if the
|
||
median beats the current best by more than 0.05.
|
||
|
||
When it finishes, the last lines tell you everything:
|
||
|
||
```
|
||
[skillopt] Outcome: accepted
|
||
[skillopt] Best sel-score: 0.840
|
||
[skillopt] Final cost: $0.71
|
||
[skillopt] SKILL.md rewritten with 6 optimization steps.
|
||
```
|
||
|
||
### Reading the outcome
|
||
|
||
| Outcome | Exit code | What it means | What to do |
|
||
|---|---|---|---|
|
||
| `accepted` | 0 | A candidate beat the baseline. SKILL.md was rewritten (or a proposed file written — see Step 5). | Review the diff, keep it. |
|
||
| `no_improvement` | 1 | Nothing cleared the gate. Your skill is already good, or the benchmark can't tell good from bad. | Strengthen the benchmark (Step 6) or stop. |
|
||
| `aborted` | 2 | A gate stopped it: dirty working tree, over budget, `D_sel < 5`, or `--dry-run`. | Read the message — it names the gate. |
|
||
|
||
`no_improvement` is not a failure. It's the gate doing its job: it would rather
|
||
keep your known-good skill than accept a change it can't prove is better.
|
||
|
||
## Step 4: See what changed
|
||
|
||
The optimizer leaves a full audit trail under the skill:
|
||
|
||
```bash
|
||
ls skills/meeting-prep/skillopt/
|
||
```
|
||
|
||
```
|
||
best.md ← the current winning version (== SKILL.md when accepted)
|
||
versions/
|
||
v0001_e1_s1.md ← every step's candidate, so you can diff any of them
|
||
v0002_e1_s2.md
|
||
...
|
||
history.json ← append-only record of every accept/reject + scores
|
||
rejected.json ← edits that were tried and didn't help (so it won't retry them)
|
||
```
|
||
|
||
The actual change to your skill is a normal git diff:
|
||
|
||
```bash
|
||
git diff skills/meeting-prep/SKILL.md
|
||
```
|
||
|
||
Run-level events (cost, model, scores per run) also land in the rotating audit
|
||
log at `~/.gbrain/audit/skillopt-YYYY-Www.jsonl`.
|
||
|
||
## Step 5: Accept or reject — and the bundled-skill rule
|
||
|
||
**For a skill you own** (your own `skills/` dir): an `accepted` run rewrites
|
||
`SKILL.md` in place. It's already a git diff — review it, then `git commit` to
|
||
keep it or `git checkout` to throw it away. Nothing is committed for you.
|
||
|
||
**For a skill that ships with gbrain** (anything under the gbrain repo's own
|
||
`skills/`): SkillOpt refuses to overwrite it by default and writes the winner to
|
||
`skills/<name>/skillopt/best.md` instead, so an optimization pass can never
|
||
silently mutate a skill other people depend on. Two ways to handle that:
|
||
|
||
```bash
|
||
# See the proposed improvement without touching SKILL.md (works for ANY skill):
|
||
gbrain skillopt meeting-prep --split 1:1:1 --no-mutate
|
||
# → writes skills/meeting-prep/skillopt/best.md (the proposed rewrite), prints its path. Copy what you want.
|
||
|
||
# Actually rewrite a bundled skill (explicit opt-in + an independent held-out set):
|
||
gbrain skillopt brain-ops --split 1:1:1 --allow-mutate-bundled \
|
||
--held-out skills/brain-ops/held-out.jsonl
|
||
```
|
||
|
||
Rewriting a bundled skill in place now requires BOTH `--allow-mutate-bundled` AND
|
||
`--held-out <path>` (a JSONL with the same shape as your benchmark, but at least 5
|
||
tasks whose IDs don't appear in the benchmark). The held-out set is how the run
|
||
proves the edit didn't just learn the benchmark: a candidate that climbs the
|
||
benchmark but slips on the held-out tasks is refused. Drop `--held-out` and the
|
||
run hard-refuses and points you at `proposed.md` instead.
|
||
|
||
Rule of thumb: `--no-mutate` when you want to read the diff before trusting it
|
||
(no held-out needed); `--allow-mutate-bundled --held-out` only when you intend to
|
||
commit a proven change to a shared skill.
|
||
|
||
## Step 6: Iterate
|
||
|
||
The loop that actually makes skills better:
|
||
|
||
1. Run it. If `no_improvement`, the benchmark probably can't distinguish good
|
||
from bad yet.
|
||
2. Add tasks that capture what you wish the skill did differently. Saw the agent
|
||
skip citations? Add `{"op":"min_citations","arg":2}`. Saw it ramble? Tighten
|
||
`max_chars`.
|
||
3. Re-run. A sharper benchmark gives the optimizer a real gradient to climb.
|
||
4. When a run lands `accepted`, read the diff, commit it, and bank the win.
|
||
|
||
The skill you ship gets better every time the benchmark gets sharper. That's the
|
||
whole game: you're not editing prose, you're improving the definition of done and
|
||
letting the optimizer chase it.
|
||
|
||
## What you built
|
||
|
||
You wrote a benchmark that encodes what "good" means for one skill, previewed the
|
||
cost, ran the optimizer, and either accepted a measurably better skill or learned
|
||
your benchmark needs sharpening. Same loop scales to every skill you own — and
|
||
`gbrain skillopt --all` runs it across every skill that has a benchmark, under a
|
||
brain-wide cost cap.
|
||
|
||
## Where to go next
|
||
|
||
- **Full flag + exit-code reference, cost model, safety guards:**
|
||
[`docs/guides/skillopt.md`](../guides/skillopt.md)
|
||
- **Every flag inline:** `gbrain skillopt --help`
|
||
- **Batch + fleet + background runs** (`--all`, `--target-models`, `--background`),
|
||
**LLM and qrels judges**, **held-out test sets**, and **resume after a crash**
|
||
(`--resume <run-id>`): all in the reference guide above.
|
||
- **Generate a starter benchmark from the SKILL.md** (the recommended way to start):
|
||
`gbrain skillopt <name> --bootstrap-from-skill` → review + strengthen the judges →
|
||
delete the sentinel → `--bootstrap-reviewed --split 1:1:1`. Tune the count with
|
||
`--bootstrap-tasks N` (max 50).
|
||
- **Bootstrap from existing routing fixtures** instead: `gbrain skillopt <name>
|
||
--bootstrap-from-routing` (routing tasks test dispatch, not quality — tighten them).
|