Standalone Python stdlib pipeline that reads an agent's past sessions, compares them against installed skills, and generates structured improvement proposals gated by an evaluation framework before anything mutates. Host-agnostic via HostAdapter (Hermes, Claude Code). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
66 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
What this is
A host-agnostic agent skill (skill-evolution) that gives the host agent a self-improvement feedback loop: it reads the agent's past session transcripts through the active HostAdapter (default Hermes → ~/.hermes/state.db; Claude Code → ~/.claude/projects/*/*.jsonl), compares them against the agent's installed skills, generates structured markdown proposals (improve_existing, create_new, merge_skills, deprecate_skill) for a human to review, and — if opted in — auto-applies high-confidence proposals through the host's mutation channel (Hermes: skill_manage instructions; Claude Code: direct file writes), gated on an evaluation framework that scores proposal content before anything mutates.
There is no daemon and no server component. The "pipeline" is a set of standalone Python stdlib scripts that a scheduled job invokes and pipes together; the actual analysis step is an LLM call made by the host agent itself (not by any script here), following an analyzer prompt that the operator maintains outside this repo (see SKILL.md's "Scheduled Runs" section for what that prompt needs to do). The evaluation gate (scripts/evaluate.py), by contrast, is implemented in this repo — it's not delegated to the cron agent.
Git tracking
.gitignore used to use unanchored patterns that matched several directory names anywhere in the tree, silently excluding real pipeline code from version control while git status read clean. This was fixed by anchoring the patterns.
proposals/, reports/, and eval_history.jsonl are gitignored and not shipped by this repo — proposals/ in particular holds real analysis output derived from actual session transcripts, and free-text personal narrative a model can write into a proposal body isn't reliably caught by shape-based redaction, so these stay local-only rather than committed. This has no effect on runtime behavior: the pipeline still reads/writes these paths exactly as described below; only what git tracks is affected. scripts/*.py and tests/*.py remain fully tracked.
proposal.py's get_proposals_dir() defaults to ./proposals/ at the repo root
(overridable via SKILL_EVOLUTION_PROPOSALS_DIR), and evaluate.py's history file
defaults to ./eval_history.jsonl at the repo root (overridable via
SKILL_EVOLUTION_HISTORY_PATH). The skill-quality cron wrapper writes timestamped markdown
reports to ./reports/ (overridable via SKILL_EVOLUTION_QUALITY_REPORT_DIR).
Commands
# Run the pipeline manually, end to end (mirrors what the cron job does)
python3 scripts/fetch_sessions.py --dry-run # preview sessions without marking them processed
python3 scripts/fetch_sessions.py | python3 scripts/analyze.py # NDJSON sessions -> LLM-ready prompt text
python3 scripts/skill_index.py # scan ~/.hermes/skills/ -> JSON index
# Proposal inspection/scaffolding
python3 scripts/proposal.py --example # print an example proposal
python3 scripts/proposal.py --list # list current proposals
python3 scripts/proposal.py --show <id> # show one proposal by id
# Evaluation gate
python3 scripts/evaluate.py --list-evaluators # show the resolved evaluator set
python3 scripts/evaluate.py --retroactive --dry-run # preview batch re-evaluation of saved proposals
python3 scripts/evaluate.py --retroactive # re-run evaluation against saved proposals, append history
python3 scripts/evaluate.py --prune # prune eval history per SKILL_EVOLUTION_HISTORY_RETENTION
# One-off evaluation of any of the four targets (advisory by default — only `skill` and
# `proposal` gate auto-apply; see SKILL_EVOLUTION_GATE_TARGETS below).
# `deterministic` is meaningless for the non-skill targets, so opt out of it.
SKILL_EVOLUTION_EVALUATORS=llm_judge,regression python3 scripts/evaluate.py --eval-target proposal --proposal-id <id>
SKILL_EVOLUTION_EVALUATORS=llm_judge,regression python3 scripts/evaluate.py --eval-target tool_calls --session-id <id>
SKILL_EVOLUTION_EVALUATORS=llm_judge,regression python3 scripts/evaluate.py --eval-target analyzer_prompt --session-id <id> --proposal-id <id>
# Inspect processed-session state (read-only; state.py is a library, not a CLI)
python3 -c "import sys; sys.path.insert(0,'scripts'); import state; print(len(state.load_processed()), 'sessions already processed')"
python3 scripts/fetch_sessions.py --prune-state # prune state per SKILL_EVOLUTION_STATE_RETENTION (reports on stderr)
# Optional GEPA optimizer (needs `pip install -e ".[optimizer]"`)
SKILL_EVOLUTION_OPTIMIZER_ENABLED=true python3 scripts/optimize_skill.py --list-candidates # read-only report of low-scoring targets (skill: targets only)
- **`scripts/skill_quality.py`** — periodic skill quality tracking (P2-3, shipped 2026-08-01). Evaluates all installed skills against the same rubric as the evaluation gate (correctness, procedure-following, conciseness), records each evaluation in `eval_history.jsonl` under `skill:<name>`, and generates a markdown or JSON report showing trends over time. Reuses `evaluate_skill_text()` with a synthetic proposal (field=body, same old/new value so size guards pass), `installed_skill_body()` for skill content, and the existing provider infrastructure. CLI: `--skill <name>` (evaluate one), `--output <file>` (write report), `--since Nd` (filter by recency), `--below 0.7` (filter by threshold), `--format json|markdown`. Cost: one LLM judge call per skill (~$0.01-0.05), so a full tree of 143 skills costs ~$1.50-7.00 per run — recommend weekly/monthly, not daily. Cron wrapper: `scripts/skill-quality-report.sh`.
SKILL_EVOLUTION_OPTIMIZER_ENABLED=true python3 scripts/optimize_skill.py --list-candidates --target all # include proposal:/tool_calls:/analyzer_prompt: namespaces
SKILL_EVOLUTION_OPTIMIZER_ENABLED=true python3 scripts/optimize_skill.py --skill <name> [--iterations N] # run GEPA for one skill
# Tests
pip install -e ".[dev]"
pytest
Interpreter note. The core pipeline is stdlib-only, so any python3 runs it. The optimizer is not: gepa must be importable, so optimize_skill.py --skill needs the interpreter you installed the [optimizer] extra into. A project-local .venv on pyenv 3.12.2 is the setup these commands were verified against — use .venv/bin/python scripts/optimize_skill.py ... for optimizer commands, or the bare python3 shown above for everything else. Running the optimizer under an interpreter without gepa fails fast with _require_gepa()'s actionable RuntimeError rather than misbehaving. pytest passes under either interpreter — the gepa-contract module skips when the extra is absent; see "Tests exist now" below for the currently-verified pass/skip counts rather than a number duplicated here (a stale duplicate is exactly how this line and that one drifted out of sync with each other before).
No build step and no non-stdlib runtime dependencies for the core pipeline. gepa (the standalone PyPI package, not dspy) is an optional extra (pip install -e ".[optimizer]") required only by scripts/optimize_skill.py; nothing else imports it.
Architecture
fetch_sessions.py ──NDJSON──► analyze.py ──formatted prompt──┐
skill_index.py ──JSON index──────────────────────────────────┼──► [host agent + analyzer prompt]
│
▼
proposal.py (write proposal .md files, status: proposed)
│
human review (status: approved)
│
apply_proposal() ──► evaluate.evaluate_and_record()
│ (deterministic + llm_judge + regression,
│ appends to eval_history.jsonl)
│
gate passed? ──No──► can_apply: False, proposal stays proposed
│
Yes
│
▼
skill_manage (Hermes tool)
optimize_skill.py (optional, SKILL_EVOLUTION_OPTIMIZER_ENABLED=true)
--list-candidates: reads eval_history.jsonl, prints low-scoring targets (read-only, no proposal written)
--skill <name>: fetch_sessions.sessions_for_skill() ──► gepa.optimize_anything() ──► drafts improve_existing
proposal from the winning candidate via proposal.save_proposal()
(never calls apply_proposal() itself — optimizer-originated proposals go through the same gate as any other)
scripts/fetch_sessions.py— reads~/.hermes/state.db(SQLite;sessions/messagestables), filters out already-processed sessions (viastate.py's state file) and anything older than--lookback-hours, redacts messages viacontains_secret()— which layers the fixedSECRET_PATTERNSsubstrings (known vendor prefixes, specific env-var names) with shape-basedSECRET_REGEXES. The regex layer exists because the substring list provably leaked: scanning the real 26,794 message bodies found 27password=assignments, 23 generic*_TOKEN=/*_SECRET=assignments, 6 genericsk-keys, 5Authorization: Bearerheaders and a credentialed DB URI that it missed, all of which would have been forwarded to a provider verbatim. Length floors (20+ chars for opaque keys, 8+ for assigned values) keepMAX_TOKENS=1024andDEBUG=trueoff the list; measured over-redaction is 0.229% of lines. This predicate is the single chokepoint for both redaction points (provider prompts and on-disk proposals), so err toward over-detection — appliesredact_pii()(see below), truncates long messages, and emits one JSON object per session to stdout. Marks sessions processed as a side effect unless--dry-run. Its queries selectsessions.started_at/messages.timestamp(matching the live schema); the per-session output dict's timestamp key isstarted_at(there is nocreated_atkey, andtotal_tokensis not emitted —analyze.pydefaults it).sessions_for_skill()(used by the optimizer) is a separate query on the same DB that filters sessions to those touching a given skill, capped atmax_sessionsmost-recent-first. The two functions handle an unreadable DB differently, on purpose:sessions_for_skill()checks existence and thesessionstable via_session_db_is_readable()before connecting and returns[]with a stderr warning, because the optimizer is interactive and "no history for this skill" is a normal outcome there;fetch_sessions()still raises, because it is the unattended cron path where an unreadable DB is a failure the operator needs surfaced rather than a run quietly reporting zero sessions every night. Checking beforesqlite3.connect()also matters becauseconnect()creates a file for a missing path — a typo'd--db-pathpreviously crashed with a confusing "no such table: sessions" and left a stray 0-byte DB behind.- Secrets and PII are handled differently, on purpose. A detected secret drops the whole message (
_summarize_messagesskips it) — there is no version of a message worth sending once it carries a credential. Detected PII is masked in place byredact_pii(), so an email inside a paragraph of useful debugging evidence costs that email, not the paragraph, and the mask names the kind ([PII:EMAIL]) so a reviewer knows what was removed. It is wired at both redaction points:fetch_sessions._summarize_messages()(what reaches the analyzer) andevaluate.redact_secrets()(provider prompts and on-disk proposals). Monetary amounts are deliberately NOT masked. The scope is identifiers, not amounts: an email, phone, Luhn-valid card, IBAN or RUC/DNI identifies a person, while a bare amount identifies nobody and is exactly the evidence the analyzer needs to reason about finance/budgeting-style skills. Measured over a real message corpus, on what actually crosses the boundary: identifier patterns (email/phone/card/RUC) were fully masked while money amounts passed through untouched, confirming the redaction targets identifiers specifically rather than over-redacting numeric content generally. Card detection requires a leading digit of 3–6 and a Luhn check, which is what keeps hash-like 16-digit runs (commit 1234567890123456) out. scripts/skill-evolution-fetch.sh— the bash wrapper a cron job invokes, so the cron entry stays one stable command. It resolvesfetch_sessions.pyrelative to its own location, following symlinks, and passes arguments through. Two earlier strategies were broken and are worth not reinventing: probing a hardcoded skills-home path with a fallback that doesn't exist in this repo's layout (always failed), andif git rev-parse --show-toplevel; thento find the project root (that command's stdout isn't captured, so the repo path was printed to stdout and prepended a stray non-JSON line to the NDJSON streamanalyze.pyconsumes). Nothing butfetch_sessions.pymay write to stdout. Symlink-following matters whenever the deployed entry point is itself a symlink into this repo, which is a common way to install it.scripts/state.py— small JSON-file-backed tracker (~/.hermes/skill_evolution_state.json) for which session IDs have already been analyzed.fetch_sessions.pyduplicates this logic inline rather than importing it — if you change one, check the other (tests/test_state_schema_compat.pyparametrizes over both modules to catch drift). Two on-disk shapes exist and both must be read. This repo and the deployedSKILL.mddocument{"processed_sessions": [...], "last_analyzed_at": ..., "version": 1}, but the file actually deployed is a flat{session_id: iso_timestamp}map (101 entries). Reading only the documented shape returned[]every run, which silently disabled dedup entirely —--dry-run --lookback-hours 168reported 20 unprocessed sessions that had all in fact been processed.mark_processed()now preserves whichever layout the file already has (writing the documented shape only for a fresh file) because rewriting the flat map would destroy timestamps another producer maintains. Don't "normalize" that file without confirming who else writes it. Redirecting state:SKILL_EVOLUTION_STATE_FILEoverrides the path, resolved at call time byget_state_file()— present in both modules, since they duplicate deliberately. Its fallback is the module global rather than the literal path, so monkeypatchingfetch_sessions.STATE_FILE(whattests/test_state_schema_compat.pydoes) still works; a blank value is ignored rather than redirecting to"". Note that once dedup works,fetch_sessions.py --dry-runlegitimately returns 0 sessions because every real session is already marked processed, which looks like a failure but is correct. Host-prefixed since the host-agnostic read side (U2):load_processed()/mark_processed()/prune_processed()all take ahostargument (defaulting tohost.resolve_host(), duplicated locally in both modules rather than imported —host.pyimportsfetch_sessions.py, so importing back would cycle). New entries on the flat-map shape are written"<host>:<session_id>"; a legacy un-prefixed key is read as belonging to"hermes"only, which is what lets the 101 real pre-host entries keep working with zero migration.prune_processed()only ever touches the resolved host's own entries — a different host's rows, prefixed or legacy, pass through untouched, andkeep_idsis matched against each entry's bare id rather than its on-disk key for the same reason. This is not yet expressed as aHostAdaptermethod (scripts/host.pydoesn't have a fourth "processed state" method) — see that file's section above for the three methods it does have. Pruning:prune_processed(retention=None, keep_ids=None), also duplicated in both modules (tests/test_state_pruning.pyparametrizes over both, same drift-protection convention), gated onSKILL_EVOLUTION_STATE_RETENTION(same bare-int/"Nd"/"Nmo"syntax asevaluate.py's retention parsing, duplicated locally rather than imported —state.py/fetch_sessions.pysit belowevaluate.pyin this repo's import direction and must not invert it). Real pruning applies only to the flat{id: timestamp}shape — the documented shape carries no per-session timestamp, and its list order isn't chronological either (fetch_sessions()builds it from a Pythonsetunion, whose iteration order is hash-based, not insertion-based), so there is no temporal signal to prune by there without a schema change, which this deliberately does not attempt. Configuring retention against a documented-shape file is a no-op with a stderr warning on every call, not a silent gap.keep_idsis structurally required, not a defensive nicety.mark_processed()computesnow()once per call and stamps every session in that batch with the identical value, so "keep the N most recent by timestamp" has no defined tiebreak among everything written in the same run — without a floor, a small retention count could drop most of the very batch a run just wrote, making those sessions look unprocessed again on the very next run.fetch_sessions()(the only real orchestrator ofprune_processed()for the Hermes path) computesjust_processed = {s["session_id"] for s in results}(distinct fromnew_processed, the full accumulated union) and callsprune_processed(keep_ids=just_processed)immediately aftermark_processed(new_processed), structurally after the--dry-runearly return so a dry run never prunes either.keep_idscloses the same-run hole, not the cross-run one. IfSKILL_EVOLUTION_STATE_RETENTIONis configured tighter than the effective--lookback-hours, a session's state entry can be pruned while it's still inside a later run's query window — it no longer looks processed, gets re-fetched, re-marked, and re-analyzed, indefinitely, every time this recurs. There's no clean structural fix without coupling retention to lookback, so the mitigation is a warning, not a fix:_warn_if_state_retention_below_lookback()fires on stderr every run where age-based retention converts to fewer hours than--lookback-hours— count-based retention has no clean unit match to compare, and has its own related limitation (keep_idsoverriding the limit means a busy day's batch can floor the file above the configured count, so age-based retention is the better fit for this file specifically). Manual escape hatch:fetch_sessions.py --prune-state, structurally likeevaluate.py --prune(before/after count, early return) but reporting to stderr, not stdout — unlikeevaluate.py, this script's stdout is the NDJSON channel other tooling parses.scripts/skill_index.py— scans~/.hermes/skills/<category>/<skill>/SKILL.md, hand-parses the YAML frontmatter (no PyYAML dependency — stdlib only) viaparse_name_description_frontmatter()forname/description, and emits a JSON index the analyzer prompt uses as "what skills currently exist."evaluate.py'sDeterministicEvaluatorreuses this same parser to validate a proposal's frontmatter.scan_skills()skips dot-prefixed category directories: a live tree can hold.archive/(retired skills),.curator_backups/(timestamped snapshots) and.hub/(lockfiles, audit log, quarantine). Without that filter the index reported retired skills as currently existing, and an archived twin of a live skill (the same skill name present in both.archive/and a live category) madeoptimize_skill.py --skillreject the live skill as an ambiguous duplicate.scripts/analyze.py— pure formatter: NDJSON sessions on stdin → markdown-ish text on stdout for LLM consumption. No analysis logic lives here; the actual judgment is made by whatever LLM/agent reads this text plus the analyzer prompt. Its only reusable function isformat_session(session) -> str(one session at a time) — there is no batch formatter, so an in-process caller joins the per-session strings itself;main()is the stdin/stdout wrapper. Formatted session text plus the analyzer instructions plus the skill index can add up to a sizeable prompt, so provider context limits are a real design constraint on how many sessions one analysis pass can carry —--lookback-hours/max_sessionsexist to bound that.scripts/proposal.py— the schema and I/O layer:SkillEvolutionProposaldataclass +ProposalType/ProposalStatusenums define the proposal shape.render()/load_proposal()do round-trip markdown+YAML-frontmatter serialization using a hand-rolled frontmatter parser (_parse_frontmatter, no PyYAML) — it's whitespace-sensitive (2-space list items, 4-space nested change blocks). Be careful preserving exact indentation if you edit proposal rendering.get_proposals_dir()defaults to./proposals/at the repo root (overridable viaSKILL_EVOLUTION_PROPOSALS_DIR) — see "Git tracking" above.list_proposals()skips files it cannot parse but names each one and its reason on stderr. It used to swallowValueError/IOErrorentirely, and that fired for real: a model wrotestatus: already_covered(not inProposalStatus),load_proposal()raised, and--list/--retroactivereported 7 of 8 files with no hint the eighth existed. Any model-authored field can do this, since the analyzer writes these files. The parse stays strict — the fix was making the loss audible, not accepting unknown values — because a proposal is a unit of work awaiting a human decision, and a file that errors gets looked at while a file that vanishes does not.save_proposal()pipes the rendered markdown throughevaluate.redact_secrets()before writing to disk, so a proposal can never persist a secret that leaked into session evidence or a proposed change.apply_proposal()validatesstatus == proposedandconfidence >= min_confidence, resolves the active host adapter, and refuses (before the evaluation gate runs at all) if that host can't write skills or if the proposal can't be expressed as a mutation — acreate_newwith a missing/placeholderbodyis a drafting failure, not a valid mutation (acreate_newproposal has, in practice, shipped a literal placeholder string instead of real content, which Hermes'sskill_managerejects forcreate). Then it runs the evaluation gate (evaluate.evaluate_and_record()) before mutating anything. If evaluation raises or the gate fails, it returnscan_apply: False(withevaluation_resultsand, on a raised exception,evaluation_error) and leaves the proposal's status untouched. Only once the gate passes does it build a normalized mutation plan and hand it toadapter.apply_skill_write(plan): the Hermes adapter returnsskill_manageinstruction dicts (thecreateinstruction carriesname+body, fixing the placeholder-body bug above) that the agent runs later, while the Claude Code adapter writes skill files directly and immediately.apply_proposal()flipsstatustoappliedonly after the adapter reportscan_apply: True, and the create_new history migration runs only then too — a refused write must never leave an orphaned migration. It does not callskill_manageitself — this script has no Hermes runtime dependency.proposal.pyimportsevaluateat module scope;evaluate.pyavoids importingproposalat module scope (it imports it locally inside_select_retroactive_proposals) specifically to break that cycle — keep that direction if you touch either file's imports.
scripts/evaluate.py— the evaluation/gating framework (see "Evaluation framework" below).scripts/optimize_skill.py— optional GEPA optimizer, gated behindSKILL_EVOLUTION_OPTIMIZER_ENABLED=trueand the optional standalonegepaPyPI package (notdspy).--list-candidatesprintsfind_low_scoring_targets()'s entries (history belowDEFAULT_LOW_SCORE_THRESHOLD0.6) read-only, with no optimization run.--skill <name> [--iterations N]runsrun_gepa_optimization(): resolves the skill's installedSKILL.mdviaskill_index.scan_skills()as the seed candidate, fetches that skill's session history viafetch_sessions.sessions_for_skill()(capped atDEFAULT_MAX_SESSIONS_FOR_SKILL, most recent first; exits cleanly belowMIN_SESSIONSwithout importinggepaat all), then callsgepa.optimize_anything()in Single-Task Search mode with an objective built by_build_objective(seed_body, skill_name)— which states the seed body's byte count and the allowed range, plus an instruction to preserve existing section headings. The range is the intersection of all four limits the gate applies, computed and intersected before any branching: the per-pass percentages (SKILL_EVOLUTION_MAX_GROWTH_PCT/MAX_SHRINK_PCT) against the body being replaced, the absolute per-pass deletion floor (MAX_SHRINK_BYTES), the absolute cap applied as the same ratchet the gate applies (MAX_SKILL_SIZE_KB— an oversized seed getsupper = base, i.e. it may not grow at all), and the cumulative percentages againstoriginal_size_for_target(). Each omission was the same bug in a new place: stating only the per-pass window over-advertised headroom for a skill partway toward its cumulative ceiling, and ignoring the cap advertised a ~124KB upper bound for the 103,656B skill that the gate rejects outright. An earlier version returned early from inside the cumulative block, so a window emptied by a different constraint went undetected — hence one intersection, then one branch. Notes explaining why the window is narrow are emitted in precedence order (ratchet → cumulative → byte floor); the byte-floor note is not cosmetic, since without it an oversized skill is handed a 2KB-wide window on a 100KB body with no stated reason. If the intersection is empty the objective says so, and which advice it gives depends on which side is violated: over the growth ceiling → reword within the current length (baseis still admissible on the floor side); below the shrink floor → no revision of any length passes, so it says the target needs human attention rather than advising a same-length rewrite that would also be rejected. That second branch fixes advice that was silently wrong before. Without that budget the objective named only the task, so the reflection LM optimized purely for score and only met the size limits after the run: one real run spent its entire metric-call budget converging on a +121.8% candidate that was inadmissible from the first byte. It is a soft defence (a prompt the model may ignore) that does not replace the deterministic check — its value is not wasting budget on candidates born dead, scored byscore_candidate()(built onevaluate.py's provider/redaction layer, reusingLLMJudgeEvaluator.parse_and_score()) withgepa's own reflective-mutation step also routed through that same provider layer via_reflection_lm_adapter()rather thangepa's litellm/OpenAI default.draft_proposal_from_gepa_result()builds animprove_existingproposal from the winning candidate (rejecting structurally broken candidates before drafting), with a rationale reporting the full explored-candidate frontier. It never callsapply_proposal()— optimizer-drafted proposals re-enter the same human-review/auto-apply gate as any analyst-drafted one. The score-provider's judgefeedback(whichgepaembeds verbatim into its own internal reflection prompt) is produced under an explicit instruction not to quote session content verbatim and is wrapped in the same untrusted-content framing used elsewhere — narrowing, but not eliminating, a paraphrased-injection residual risk (gepa's own internal prompt construction is out of reach). The three tuning constantsMIN_SESSIONS(3),DEFAULT_MAX_METRIC_CALLS(8), andDEFAULT_MAX_SESSIONS_FOR_SKILL(20) are overridable viaSKILL_EVOLUTION_OPTIMIZER_MIN_SESSIONS,SKILL_EVOLUTION_OPTIMIZER_MAX_METRIC_CALLS, andSKILL_EVOLUTION_OPTIMIZER_MAX_SESSIONS_FOR_SKILL; an explicit--iterationsalways overridesDEFAULT_MAX_METRIC_CALLSregardless.optimize_skill.pyhas been run against the realgepa==0.1.4package and real session history, which is what these defaults are tuned from: a real optimizer run against a skill with an explicit--iterations 4surfaced both a real score gain and a pathological +121.8%-growth failure inside those 4 calls, and took over two minutes wall-clock against a hosted provider (30s+/metric-call) — at the old default of 20 that's a 10+ minute unattended foreground call for what is, by design, a manual/occasional invocation (never cron-triggered). 8 keeps 2x headroom over what was empirically sufficient while bounding the worst case instead of doubling it on no further evidence.MIN_SESSIONSandDEFAULT_MAX_SESSIONS_FOR_SKILLwere left unchanged: real runs never testedMIN_SESSIONS's boundary, and both 8- and 20-session trainsets ran fine, so neither constant has evidence pointing anywhere in particular.- The analyzer prompt (maintained by the operator outside this repo, not shipped here — see SKILL.md) is the "business logic" of the analysis half of the system: it defines the analysis criteria (skill coverage, cross-session patterns, specificity, confidence scoring 0.5–1.0), the output format matching
proposal.py's schema, and rules like skipping already-processed sessions and never calling the host's skill-mutation tool itself. The Python scripts in this repo are plumbing around that prompt, not a replacement for it. A structural gotcha worth knowing if you're writing your own:create_newproposals have notarget_skill, sotarget_key_for_proposal()falls back toproposal:<uuid>, giving each its own single-entry lineage unless the target key is derived fromproposed_changesinstead (which is what this repo'sRegressionEvaluatordoes — see below) — otherwise regression tracking is effectivelyimprove_existing-only. The evaluation half (evaluate.py) is separate: it scores whatever content a proposal already contains, regardless of which agent or prompt produced it.
Evaluation framework (scripts/evaluate.py)
Introduced to gate auto-apply on measured quality rather than just the proposal's self-reported confidence.
EvalResult(score,feedback,passed,evaluator_name) is the shape every evaluator returns.Evaluatoris the ABC; concrete evaluators register themselves into the module-levelREGISTRYdict viaregister_evaluator()at import time — there's no dynamic discovery/plugin scanning.- Which evaluators run is controlled by
SKILL_EVOLUTION_EVALUATORS(comma-separated; default"deterministic,llm_judge,regression", resolved byget_enabled_evaluators()). An unknown name raisesValueErrorrather than silently skipping. - Five evaluators, all registered at import:
DeterministicEvaluator— binary size (SKILL_EVOLUTION_MAX_SKILL_SIZE_KB, default 15KB), growth-vs-baseline (SKILL_EVOLUTION_MAX_GROWTH_PCT, default 20%), shrink-vs-baseline (SKILL_EVOLUTION_MAX_SHRINK_PCT, default 15% — deliberately tighter than the growth cap: an over-long skill is bounded by the absolute 15KB check and merely costs context, whereas deletion silently removes guidance, and 15% keeps one pass's bite to ~1.4KB of a median 9.7KB skill instead of ~1.9KB), and YAML-frontmatter-structure checks. The shrink floor is not symmetry for its own sake: the LLM judge'sconcisenesscriterion rewards deletion, and a real GEPA run cut a skill body 10258B → 3041B (−70.4%), lost 17 of 29 headings including itsRed Flags/Common Rationalizations/Anti-Patternssections, and scored higher for it (0.85 → 0.90). A cap without a floor guards against bloat but not against content destruction. Both per-pass deltas are gated oncontext["baseline_size"]— seeevaluate_skill_text()below. Plus two cumulative limits measured againstcontext["original_size"]— where the target started, not the body it immediately replaces:SKILL_EVOLUTION_MAX_CUMULATIVE_GROWTH_PCT(default 50%) andSKILL_EVOLUTION_MAX_CUMULATIVE_SHRINK_PCT(default 30%). The per-pass limits reset their reference every pass, so they compound: at 20% per pass, four accepted passes halve a skill while each one looks compliant, andRegressionEvaluatorcan't catch it because every deletion raises the judge'sconcisenessscore, so each pass legitimately outscores the last. The erosion is self-reinforcing. Verified: simulating repeated exact-floor cuts, the gate halts at pass 3 with 62% of the original retained instead of running to zero. Note the cumulative limit governs the final retained fraction, not the per-pass one — retuning the per-pass floor changes how many passes it takes to get there and how big each bite is, not where erosion stops. Cumulative allowances are deliberately wider than per-pass ones — one pass may move 20%, but total drift stays bounded. The absolute cap is a ratchet, not a flat ceiling. It fails only when the candidate is over the cap and larger than the baseline it replaces. A real installed-skill census found roughly 15% of skills already exceeding the 15KB cap (median size well under it, but with real outliers over 100KB) — a spot-check claiming far fewer was wrong; a full census is what should be trusted. Under a flat ceiling every body proposal for an over-cap skill failed on size regardless of direction, so a proposal shrinking one toward compliance was rejected with the identical message as one growing it: the gate could not tell improvement from worsening.create_newcarries no baseline, so it keeps the strict cap and a skill is never born oversized — that exemption is a consequence of the rule ("a change that replaces existing text may not worsen; a change that creates text faces the hard cap"), not a proposal-type check to add later. Note this makes oversized skills improvable, not downsizable: at 2048B/pass, walking a 100KB+ skill to compliance would take dozens of approved passes, so a very large skill still needs a human to split it. Plus an absolute per-pass deletion floor,SKILL_EVOLUTION_MAX_SHRINK_BYTES(default 2048,0disables), applied as a second sequential check after the percentage floor — the stricter of the two binds. A percentage scales with the skill, so it is weakest exactly where a deletion does most damage: 15% of the median skill is ~1.5KB, but 15% of the 103,656B one is 15,548B in a single pass. The default comes from the crossover, not from taste:2048/0.15 = 13,653B, just under the 15,360B cap, so the byte floor is inert for essentially every within-cap skill by construction and operative precisely on the oversized tail the ratchet unblocks (it binds 35 of 143). The two checks are sequential rather than onemin()so the feedback names whichever limit actually bound. These two changes are causally coupled and must not be separated: the 15KB single-pass deletion was previously masked (the shrunk candidate was still over the cap and failed there first), so ratcheting the cap without the byte floor is a net regression in deletion safety. Growth needs no byte counterpart: per-pass absolute growth is bounded atcap × g/(1+g)= 2,560B for a within-cap skill, and at 0 for an oversized one under the ratchet. That invariant scales with the cap — raisingSKILL_EVOLUTION_MAX_SKILL_SIZE_KBrescales it, so revisit if the cap moves. The frontmatter check only runs whencontext["content_kind"] == "body"— a bare description change or a merge/deprecate summary+rationale fallback is plain prose and is exempt.LLMJudgeEvaluator— rubric-based (correctness,procedure_following,conciseness, each 0–1, thresholdSKILL_EVOLUTION_LLM_JUDGE_THRESHOLDdefault 0.7). Frames evaluated content as untrusted data between a per-call random hex boundary (not a static tag) specifically so injected content can't forge the closing marker and escape the block; fails closed (self._fail(...)) on any provider error or malformed/out-of-range JSON response rather than guessing._post_json()sets a descriptiveUser-Agent(USER_AGENT = "skill-evolution/0.1"), overridable per call via theheadersargument. This is load-bearing, not cosmetic: urllib's defaultPython-urllib/3.12is blanket-blocked by OpenCode Zen's gateway, which answers 403 for it and 200 for the byte-identical request under any descriptive UA. If a provider starts returning 403 whilecurlsucceeds, check the UA before suspecting the key. Verified harmless against a real Ollama server (ollama==0.30.10): its endpoint doesn't gate on User-Agent at all, so the fix changes nothing there — confirmed via a full round trip (LLMJudgeEvaluator.evaluate()→_call_ollama()→_post_json()) scoring a real installed skill. That pass also surfaced a real, previously unknown friction point unrelated to the UA itself: a reasoning-capable local model (gemma4) used 54–56s of the 60s defaultcall_providertimeout on one judge call, and a heavier one (qwen3.5) timed out outright — local models burn a large share of their output on chain-of-thought before the JSON, in a way a hosted API typically doesn't. That friction is now tunable viaSKILL_EVOLUTION_PROVIDER_TIMEOUT(see the provider adapter layer below). Still unverified against Claude (noANTHROPIC_API_KEYin this environment).- OpenCode Zen gotcha: two catalogues exist and they are not interchangeable. Zen (
https://opencode.ai/zen/v1, the_call_opencodedefault) carriesbig-pickleand the*-freevariants (deepseek-v4-flash-free,mimo-v2.5-free, …); the Go subscription tier (https://opencode.ai/zen/go/v1) is a different, smaller list withoutbig-pickleand whose DeepSeek id has no-freesuffix. PointSKILL_EVOLUTION_OPENCODE_BASE_URLat the tier matching the model id you set. Both are OpenAI-compatible (/chat/completions, bearer auth), so the caller mirrors the Ollama one; unlike Ollama it reaches a third party, so a missingOPENCODE_API_KEYfails closed with an actionable message instead of sending an unauthenticated request. Model lists are public —curl -s https://opencode.ai/zen/v1/modelsneeds no key. RegressionEvaluator— compares a target's new aggregate score against the last entry that actually passed in that target's history (not just the last entry) so a failed attempt can never lower the bar a later one is compared against. No prior passing entry → auto-passes ("no baseline yet"). Works for bothimprove_existingandcreate_newproposals:target_key_for_proposal()extracts the skill name fromproposed_changesforcreate_new, andmigrate_proposal_history()rewritesproposal:<id>entries toskill:<name>after creation, connecting the skill's full evaluation lineage.HumanReviewEvaluator— opt-in interactive gate (P2-4). Not inDEFAULT_EVALUATORS; enable viaSKILL_EVOLUTION_EVALUATORS=...,human_review. Requires a TTY (stdin/stdoutisatty()); fails closed without one. Binary prompt: y/yes → approve, n/no → reject, empty/junk re-prompts up to 3 times then fail-closed; EOF/KeyboardInterrupt fail-closed. Runs last inrun_evaluators()after automatic + regression, receivesprior_resultscontext. Approve/reject returnnew_score(the automatic aggregate), mirroringRegressionEvaluator— a hardcoded 1.0 would inflateeval_history.jsonlgate entries thatRegressionEvaluatorbaselines against. Fail-closed returns 0.0. Optional rejection reason captured ("no reason given"if blank). Feedback:"human approved: no note"/"human rejected: <reason>".scripts/skill_quality.pystripshuman_reviewfrom the env var at startup (warns on stderr) to prevent per-skill interactive prompts in the cron wrapper.EmbeddingSimilarityEvaluator— opt-in semantic similarity checks (P2-5). Not inDEFAULT_EVALUATORS; enable viaSKILL_EVOLUTION_EVALUATORS=...,embedding_similarity. Requires theembeddingsextra (pip install -e ".[embeddings]"), which installsfastembed(~50MB, ONNX-based). Three modes selected automatically by context: duplicate_detection (flags content too similar to existing skills, similarity > threshold), drift_detection (flags content that drifted too far from baseline, similarity < threshold), grounding_check (flags content not grounded in source sessions, average similarity < threshold). Thresholds configurable viaSKILL_EVOLUTION_EMBEDDING_DUPLICATE_THRESHOLD(0.85),SKILL_EVOLUTION_EMBEDDING_DRIFT_THRESHOLD(0.70),SKILL_EVOLUTION_EMBEDDING_GROUNDING_THRESHOLD(0.60). Backend architecture is extensible viaSKILL_EVOLUTION_EMBEDDING_BACKEND(defaultfastembed); other backends (ollama,openai,llama_cpp) are stubbed with implementation examples in their docstrings. Registration gotcha: unlike the other four evaluators (registered atevaluate.pymodule-import time),embedding_similarity's registration only happens insideevaluate.py'sif __name__ == "__main__":block (scripts/evaluate.py:2116-2123) — nothing else in the pipeline imports it. Runningpython3 scripts/evaluate.py ...picks it up automatically; importingevaluateas a library (which is whatproposal.py'sapply_proposal()andskill_quality.pydo) does not, so settingSKILL_EVOLUTION_EVALUATORS=...,embedding_similarityin that path raises "Unknown evaluator" unless something upstream already didimport embedding_similarityfirst.
- Gate combination:
run_evaluators()runs in three phases — (1) automatic evaluators (everything exceptregressionandhuman_review), (2)regressionfed the mean of automatic scores asnew_score(human score never enters the regression baseline), (3)human_reviewlast withprior_resultscontext (automatic + regression verdicts).combine_gate(results, strictness)then combines perSKILL_EVOLUTION_GATE_STRICTNESS(or a per-proposal-type override,SKILL_EVOLUTION_GATE_STRICTNESS_<TYPE>, or a per-target overrideSKILL_EVOLUTION_GATE_STRICTNESS_<TARGET>, resolved byresolve_gate_strictness()— target beats type beats global):"strict"(default) requires every evaluator to pass;"majority"requires more than half. Zero configured evaluators never blocks. - Multi-target gate (P2-1):
evaluate_and_record()runs every target inresolve_gate_targets()(SKILL_EVOLUTION_GATE_TARGETS, defaultskill,proposal; unknown names raiseValueError; empty falls back to default) through the same registry and appends one combinedgateentry per gating target —skill:<name>,proposal:<uuid>, and per-sessiontool_calls:<sid>/analyzer_prompt:<sid>— each combined under its own strictness, then ANDs the per-target decisions. Targets with no data don't block: a proposal withoutsession_idsskips the session-based targets. A provider fault is flagged per-target on the entry it affected. Thekindhistory field tags each entry's lineage (skill_text/proposal/tool_calls/analyzer_prompt);migrate_proposal_history()uses it to leave proposal-document entries underproposal:<id>instead of folding them into the skill lineage. - Provider adapter layer (
call_provider()) is stdlib-onlyurllibHTTP to Claude (ANTHROPIC_API_KEY+SKILL_EVOLUTION_CLAUDE_MODEL), Ollama/llama.cpp (SKILL_EVOLUTION_OLLAMA_BASE_URL/SKILL_EVOLUTION_OLLAMA_MODEL), OpenCode Zen (OPENCODE_API_KEY+SKILL_EVOLUTION_OPENCODE_BASE_URL/SKILL_EVOLUTION_OPENCODE_MODEL), OpenAI (OPENAI_API_KEY+SKILL_EVOLUTION_OPENAI_BASE_URL/SKILL_EVOLUTION_OPENAI_MODEL, defaulthttps://api.openai.com/v1/gpt-4o), or Gemini (GEMINI_API_KEY+SKILL_EVOLUTION_GEMINI_BASE_URL/SKILL_EVOLUTION_GEMINI_MODEL, defaulthttps://generativelanguage.googleapis.com/gemini-2.0-flash), selected bySKILL_EVOLUTION_PROVIDER(defaultclaude) with an optional per-evaluator overrideSKILL_EVOLUTION_<EVALUATOR_NAME>_PROVIDER. OpenAI and Gemini follow the same_post_json()pattern as the other three branches; Gemini is the one shape outlier — it hitsv1beta/models/{model}:generateContentwith acontents[].parts[].textrequest body andcandidates[].content.parts[].textresponse, and authenticates via thex-goog-api-keyheader rather than a bearer token, keeping the key out of URLs. Both fail closed on a missing key or a malformed response shape, matching OpenCode's posture.GEMINI_API_KEYis also infetch_sessions.SECRET_PATTERNS, so it's caught by the redaction layer below likeOPENAI_API_KEY/ANTHROPIC_API_KEYalready were. The HTTP timeout is one generic knob for every provider —SKILL_EVOLUTION_PROVIDER_TIMEOUT(seconds, default 60), resolved byresolve_provider_timeout()insidecall_provider(); an explicittimeout=argument always wins. It is deliberately not per-provider (noSKILL_EVOLUTION_OLLAMA_TIMEOUT): the latency it exists for is a property of the model, not the adapter — local reasoning models spend most of their output on chain-of-thought before the JSON (a realgemma4judge call took 54–56s of the 60s default, andqwen3.5timed out outright). Every prompt is run throughredact_secrets()(reusingfetch_sessions.py'scontains_secret()/SECRET_PATTERNS, whole-line replacement) before it ever leaves the machine, independent of the redactionsave_proposal()already does before writing to disk — R18/R19 in the plan doc treat "sent to a provider" and "written to disk" as two separate redaction points, not one. - History: entries now also carry
content_sizeandbaseline_size(utf-8 bytes), which is what makes cumulative drift detectable —original_size_for_target()reads the earliestbaseline_sizefor a target (falling back to the earliestcontent_size) so a candidate can be compared against where the skill began. Both fields are optional, so pre-existing history written before size recording simply yieldsNoneand leaves the cumulative check inert.evaluate_and_record()andevaluate_skill_text()both derive the evaluated text via the shared_extract_evaluated_content()helper, so the sizes recorded are measured over exactly the text that was scored.append_history()/read_history()read/write one shared append-only JSONL file (get_history_path(), default./eval_history.jsonlat the repo root, overridable viaSKILL_EVOLUTION_HISTORY_PATH— see "Git tracking" above), keyed per-target (target_key_for_proposal()→"skill:<name>"or"proposal:<id>"fallback; forcreate_newproposals it extracts the skill name fromproposed_changesbefore falling back, andmigrate_proposal_history()rewrites history fromproposal:<id>toskill:<name>after the skill is created).prune_history()enforcesSKILL_EVOLUTION_HISTORY_RETENTION(a bare int = max versions,"90d"/"6mo"= age-based), and now archives rather than deletes: dropped entries are appended (not overwritten) to a sibling file before the primary is rewritten —get_history_archive_path(), default derived from the primary path's basename (eval_history.jsonl→eval_history.archive.jsonl), overridable independently viaSKILL_EVOLUTION_HISTORY_ARCHIVE_PATH. Writing the archive before rewriting the primary matters: a crash mid-prune then loses nothing, where the reverse order could lose data permanently. R22 in the eval plan permits either "archived or compacted" — archiving was chosen because it already satisfies R22's actual goal (a bounded primary file) without a lossy "compacted summary" record needing to independently satisfy three different readers. The survivor rule per target is up to three always-kept anchors, not one — the most recent entry (needed byoptimize_skill.find_low_scoring_targets()), the most recent passing entry, and the earliest entry, deduplicated when they coincide. The retained count per target can therefore exceed the configured limit by up to two entries; that's intentional. Two real correctness holes motivated the extra two anchors:RegressionEvaluatorbaselines against the last entry withpassed == True, not just the last entry — the old "keep only the most recent" rule could prune a passing baseline while newer failing retries survived, silently falling back to "no baseline yet" for exactly the targets that most need regression coverage; andoriginal_size_for_target()reads the earliest entry as its cumulative-drift baseline — without protecting it, trimming a target's oldest entries would silently shift that baseline forward, forgiving prior drift. Both are closed by always retaining the anchor entry itself, rather than giving those two functions a fallback read into the archive file — same safety guarantee, no second data-access path to build and maintain. Pruning now runs automatically, not just via--prune.evaluate_and_record()takesauto_prune: bool = Trueand callsprune_history()right after itsappend_history()call — a no-op by default, sinceprune_history()itself early-returns unlessSKILL_EVOLUTION_HISTORY_RETENTIONis configured. The same env var now controls both whether pruning happens and that it happens on every real evaluation from then on;apply_proposal()callsevaluate_and_record(proposal)with no extra kwargs, so it gets this for free.retroactive_reevaluate()'s loop passesauto_prune=Falseand callsprune_history()once after the whole batch instead —prune_history()rewrites the shared history file (every target, not just the one being re-evaluated), so pruning after every append in an N-proposal batch would mean N full-file passes for a result identical to pruning once.--pruneremains available for one-off backfill of pre-existing bloat, and its report now includes the archived count. evaluate_skill_text()extracts the body/description change and builds the evaluator context. It passescontent_kindandbaseline_size, measured the wayDeterministicEvaluatormeasures the candidate. That wiring is load-bearing: the growth-vs-baseline guard isif baseline_size:-gated, so omitting the key silently disables it — a proposal could balloon a skill +343% and still report "all deterministic checks passed."create_newhas no baseline, so the key stays absent and the guard stays inert rather than dividing by a zero baseline.baseline_sizecomes from the installedSKILL.md, not from the change'sold_value(_resolve_baseline()→installed_skill_body(), body changes only).old_valueis written by the analyzer LLM — the analyzer prompt asks it to emitold_value: <current value>andproposal.pypersists it verbatim — so it is proposal-supplied input, not an observation. That became load-bearing when the cap turned into a ratchet: the cap now asks "is this larger than what it replaces?", so an inflatedold_valuewould raise the very ceiling it is checked against, and a proposal claiming a 951KB baseline could ship a 950KB body past a 15KB cap while every percentage check reported a rounding-error delta. This is not mainly an adversarial concern — a duplicated or truncated transcription is an ordinary LLM failure, and--retroactivere-scores proposals whoseold_valuemay no longer describe anything on disk.optimize_skill._resolve_baseline_size_bytes()already applied this distrust at draft time; this is the same discipline at the gate. Body changes only (for a description change the installed file is not what it replaces — substituting it would read as a ~−98% shrink), best-effort (unresolvable or ambiguous skill → fall back toold_valuerather than fail a gate decision on a lookup miss), and shared withevaluate_and_record()so the size recorded in history is the size that was actually judged.- Three fast-follow evaluation targets (R5) exist as sibling functions to
evaluate_skill_text(), reusing the same registry and gate without modifying it orapply_proposal()'s call site:evaluate_proposal(proposal)— scores the proposal as a document (summary, rationale, proposed_changes). Target key:proposal:<uuid>(always, regardless of whether the proposal has atarget_skill). No baseline — proposals are evaluated on their own merits.evaluate_tool_calls(session_id_or_messages)— scores tool-call quality from session messages. Input can be a session_id (queriesstate.dbvia_fetch_session_messages()) or a list of message dicts (for testing). Target key:tool_calls:<session_id>. Content is JSON-serialized tool_calls with result snippets capped at 200 chars each, total cap 5KB.evaluate_analyzer_prompt(session_id, proposal_id)— scores whether the analyzer's generation step produced a grounded proposal. Reads session messages (truncated to 300 chars each, total cap 10KB) and the saved proposal markdown. Target key:analyzer_prompt:<session_id>. Wiring posture (P2-1):proposalgates auto-apply alongsideskillby default;tool_calls/analyzer_promptgate only if added toSKILL_EVOLUTION_GATE_TARGETS. The gate decision is the AND of every gating target, each combined under its own strictness.deterministicis opt-in only** for these targets (viaSKILL_EVOLUTION_EVALUATORS) — its size/growth checks are meaningless for non-skill-text content. The default set (llm_judge,regression) is the right one for proposal/tool-call/analyzer-prompt evaluation.
evaluate_and_record()is the single call site bothapply_proposal()and the retroactive path share — it runs each gating target (defaultskill,proposal), combines each under its own strictness, ANDs the per-target decisions, and appends one combinedEvalResult(evaluator_name="gate") per gating target to history, so both paths produce identical history semantics.- Retroactive/batch mode (
--retroactive,retroactive_reevaluate()) only re-evaluates already-saved proposals withstatus == proposedby default (_select_retroactive_proposals) — rejected/applied proposals are excluded because they're not live decisions and re-scoring them would inject entries into the same history stream the live gate regresses against; passinclude_all_statuses=True(Python-only, no CLI flag yet) to opt in.--target/--sincefilter further. - Module-identity gotcha: running
python3 scripts/evaluate.pydirectly loads it as__main__; sinceproposal.pydoesimport evaluateat module scope, the file registers itself undersys.modules["evaluate"]at the top (if __name__ == "__main__": sys.modules.setdefault(...)) so both names resolve to the same module instance (sameREGISTRY, same classes) instead ofproposal.pyre-executing and re-registering everything as a second, distinct module.
Proposal types and the write side (apply_proposal() → adapter.apply_skill_write(plan))
type |
What it means | Hermes emits (applied_by: agent) |
Claude Code does (applied_by: direct) |
|---|---|---|---|
improve_existing |
Patch a skill's description/body | one patch instruction per proposed_changes entry |
rewrites the installed SKILL.md in place (exact old_value match, else refuse) |
create_new |
New skill for a recurring uncovered pattern | one create instruction carrying name + full body (fixed to require real content, not a placeholder) + description/category |
writes a real skills/<name>/SKILL.md (refuses existing names) |
merge_skills |
Combine overlapping skills into target_skill |
one delete instruction per source_*-prefixed change, each tagged absorbed_into |
archives each source into skills/.archive/<name>/ after validating the umbrella exists |
deprecate_skill |
Remove a stale/unused skill | one delete instruction |
archives the skill into skills/.archive/<name>/ |
All four types pass through the same evaluation gate (above) before the adapter is asked
to mutate anything — the gate runs once per apply_proposal() call regardless of type,
and apply_proposal() only flips status to applied after the adapter reports
can_apply: True.
Conventions specific to this codebase
- Prefer host-agnostic design. Multi-agent support (Codex, Claude Code, Claude, future providers) is an explicit project goal, and the owner's stated principle is that the more agnostic the design, the better — so treat host-neutrality as the default for new work instead of a later retrofit. The Hermes coupling used to be four hardcoded points scattered one-per-module; the read side of that is now a real abstraction, not just an isolation convention.
scripts/host.pydefinesHostAdapter(an ABC with three read methods —iter_sessions(since=None),iter_skills(),read_skill_body(skill_name)— plus the write side:supports_write: bool = Falseand a fail-closed concreteapply_skill_write(plan)that returns{"can_apply": False, "reason": ...}unless an adapter overrides it), a plain-dictHOST_ADAPTERSregistry populated byregister_adapter()at import time (instances, not classes — a host adapter carries no per-call state, so nothing is gained by re-instantiating per lookup, unlikeevaluate.REGISTRY), andresolve_host()/get_adapter()readingSKILL_EVOLUTION_HOST(defaulthermes) in the same explicit-arg-beats-env-var-beats-default orderevaluate.resolve_provider()already uses — an unknown host name raises rather than silently reading the wrong tree.HermesAdapterreproduces today's behavior byte-for-byte by delegating tofetch_sessions.fetch_sessions()/skill_index.scan_skills().ClaudeCodeAdapter(root:SKILL_EVOLUTION_CLAUDE_CODE_HOME, default~/.claude) is the second adapter, reading skills from the flatskills/*/SKILL.mdtree (no category level —categoryis reported as the constant"user") and sessions fromprojects/*/*.jsonl, one JSONL-per-session with an undocumented, versioned record format handled defensively (allowlist role mapping, best-effort timestamp/field extraction, never raising on a malformed line or record) — it routes through the samefetch_sessions.contains_secret()/redact_pii()/_summarize_messages()redaction chokepoint rather than reimplementing it.evaluate.py(installed_skill_body()) andoptimize_skill.pynow read skills throughhost.get_adapter()instead of callingskill_index.scan_skills()directly, so both are host-neutral as of this abstraction landing. The two host-neutral contracts a new adapter must produce are the session dict shape (session_id,started_at,title,model,source,message_count,user_messages,assistant_messages,messages— the last already redacted/truncated) and the skill dict shape (name,category,description,path,size);read_skill_body()'s contract is narrower still — best-effort text orNoneon no match, an ambiguous match, or a read failure, never raise, since callers like the evaluation gate's baseline-size lookup must degrade to whatever baseline they already had rather than fail a gate decision on an unrelated lookup. The write side shipped with the same abstraction (2026-07-31).proposal.py'sapply_proposal()remains the sole mutation path, but it now builds a host-neutral mutation plan and hands it toadapter.apply_skill_write(plan), and it refuses (before the gate runs) on any host that can't write or any proposal that can't be expressed as a mutation — never spending a provider call on a proposal that can never apply.HermesAdapter.supports_write = Trueand itsapply_skill_write()re-emits the legacyskill_manageinstruction dicts byte-for-byte (returnedapplied_by: agentfor the cron agent to execute later).ClaudeCodeAdapter.supports_write = Trueand itsapply_skill_write()performs the writes directly and immediately (returnedapplied_by: direct): realskills/<name>/SKILL.mdfiles via atomic temp-file +os.replace, deprecate/merge archive sources intoskills/.archive/<name>/, and symlinked skill directories are refuse-to-write (38/41 installed Claude Code skills are symlinks into~/.agents/skills/— the adapter writes around them instead of following or replacing them). The Hermes adapter is instruction-emitting and the Claude Code adapter is file-writing, so they cannot share a code path; what they share is the gate, the plan shape, and the fail-closed posture. The processed-session state seam shipped (2026-08-01).HostAdapternow has three concrete state methods —iter_processed(),mark_processed(session_ids),prune_processed(retention=None, keep_ids=None)— plus a_state_file()hook for path resolution. Each adapter owns its own state file:HermesAdapterinherits the default (~/.hermes/skill_evolution_state.json), whileClaudeCodeAdapteroverrides_state_file()to return<CLAUDE_CODE_HOME>/skill_evolution_state.json.SKILL_EVOLUTION_STATE_FILEremains the universal override — when set, it redirects whichever host is active.fetch_sessions_for_host()andmain() --prune-statenow route through the adapter's state methods instead of calling module-level functions withhost=.state.pyis now imported byhost.pyat module scope (no cycle —state.pyimports nothing local), making it a real library rather than a test-only module. The host-prefix scheme (<host>:<id>keys) stays for legacy-entry handling and shared-override safety.fetch_sessions()(the Hermes cron path) stays byte-for-byte — it's the critical deployed path and already calls the state functions withhost="hermes"and the default path. That includes prose in prompts: the GEPAobjectivedeliberately says "this agent skill", not "this Hermes Agent skill", both to survive generalization and to avoid biasing the reflection LM toward one host's conventions. The remaining write-side follow-up is the project-level.claude/skills/tree, which is deliberately out of scope — the read side walks one flat root (SKILL_EVOLUTION_CLAUDE_CODE_HOME) and "which project" is undefined at the adapter level. - Every script is a standalone stdlib-only CLI with its own
argparseinmain(), importable for its core function (fetch_sessions(),scan_skills(),apply_proposal(),run_evaluators(), etc.) — keep new scripts consistent with this pattern rather than introducing a shared framework/package layout. - No PyYAML anywhere by design (
SKILL.mdpromises "no pip packages required") — frontmatter is parsed and rendered by hand in bothskill_index.pyandproposal.py. If a change needs richer YAML (nested structures beyond flat keys + one-level lists), it likely needs a matching parser update in_parse_frontmatter, not just arender()change. - Auto-apply is opt-in via env vars (
SKILL_EVOLUTION_AUTO_APPLY,SKILL_EVOLUTION_MIN_CONFIDENCE), read by the cron agent/prompt, not by the Python scripts themselves. The evaluation gate insideapply_proposal(), by contrast, always runs (it's not behind an opt-in flag) —SKILL_EVOLUTION_EVALUATORS/SKILL_EVOLUTION_GATE_STRICTNESSonly change which evaluators run and how strictly, not whether the gate runs at all. gepais imported lazily (_require_gepa()insideoptimize_skill.py, called fromrun_gepa_optimization(), not at module import time) so the rest of the pipeline works with the dependency absent — don't move that import to module scope._require_gepa()returns thegepa.optimize_anythingsubmodule, not the top-levelgepapackage — that submodule is the namespace holding theoptimize_anythingfunction plusGEPAConfig/EngineConfig/ReflectionConfig. The package deliberately binds the nameoptimize_anythingto the submodule (seegepa/__init__.py: "expose submodule; usefrom gepa.optimize_anything import optimize_anythingfor the function"), so reading those names off the package yields a non-callable module and threeAttributeErrors. Verified againstgepa==0.1.4; the parameter names the call site passes (seed_candidate/evaluator/objective/config,max_metric_calls,reflection_lm) are all correct for that version.- Stubbed-dependency tests can't catch API drift — every test in
tests/test_optimize_skill.pymonkeypatches_require_gepato return a hand-built fake, so it asserts the code against its own assumptions. Pair that with a contract test against the real package (tests/test_optimize_skill_gepa_contract.py, guarded bypytest.importorskip("gepa")so it skips without the optional extra). The same rule applies to seams between modules: the isolatedDeterministicEvaluatorgrowth tests passbaseline_sizein by hand, which is why nothing caughtevaluate_skill_text()never supplying it — assert wiring end-to-end, not just each side of the seam. - Tests exist now (
tests/test_*.py, one file per evaluator/feature area, run viapytest; last verified 2026-08-04: 780 passed + 1 skipped under plainpython3(nogepa, no[embeddings]extra) — the skip is the gepa-contract module, guarded bypytest.importorskip("gepa"). Under a.venvwithgepabut notfastembedit's 777 passed + 7 skipped, the 6 extra skips beingtests/test_embedding_backends.pycases guarded bypytest.importorskip("fastembed")— so the exact pass/skip split depends on which optional extras are installed, not justgepaalone. Treat any specific number here as a point-in-time sanity check, not a contract — the suite changes; re-runpytest -q -rsfor the current count rather than trusting this line) — this replaces an earlier state wherepyproject.tomlconfiguredpytestbut notests/directory existed. New evaluators or gate behavior should get a matchingtests/test_evaluate_*.py.