Status: Draft v1 (not yet implemented). The gate — agent-authored skills — landed 2026-05-20 (skill_save tool + skill.created event, skill-format.md §8.3); the curator is now unblocked as its own implementation task (Phase 2.5b).
Last updated: 2026-05-20
Periodic auxiliary-model maintenance of agent-authored skills. The curator pins what is being used, archives what has gone stale, consolidates near-duplicates, and patches small body errors — bounded by a shared cost cap, never auto-deleting, and never touching user-authored skills.
The pattern is lifted from Nous Research’s hermes-agent (
agent/curator.py, ~75KB; defaultsinterval_hours=168,stale_after=30d,archive_after=90d). Hermes treats this as the self-improving loop that makes a long-lived agent’s skill library survive contact with reality: skills that get loaded a lot earn priority; skills that never get loaded shouldn’t pay rent in the discovery index forever; near-duplicates merge themselves.Metis adopts the pattern verbatim where it is cheap and good, and tightens two spots: (a) the curator runs against the trace store’s
skill.loadedevents as the activity signal (no separate activity table), and (b) the auxiliary-model spend lives under the sameBudgetTrackerprimitive the evaluator uses, so a runaway curator can’t outspend a runaway evaluator silently.
The skills loader (skill-format.md) is read-only: it
parses SKILL.md files the user already put on disk. As Phase 2.5 lands
agent-authored skills (skill.created with source="auto_generated"
per event-bus-and-trace-catalog.md §6.6),
the library starts to grow without an operator pruning it. Three failure
modes show up empirically in agent-authored libraries:
code-review-python then later
python-code-review then later review-python-code. Each occupies a
distinct skill_id and a distinct discovery-index line, all describing
substantially the same procedure. The agent stops knowing which to load.The curator is the periodic auxiliary-model task that owns these three. It runs bounded (per-run and per-day cost caps), rarely (default weekly), and safely (never deletes; never touches user-authored skills; every action emits an audit event).
This spec depends on:
skill-format.md for the Skill / SkillStore types,
the two-root merge (global + workspace), and the agentskills.io frontmatter
contract (which this spec does not extend — invariant 4 in §11).event-bus-and-trace-catalog.md §6.6 for
skill.loaded (the activity signal) and skill.created / skill.modified
(the existing skill-domain events).evaluator.md §7 for the
BudgetTracker
primitive (per-run + per-day USD caps with a single source of truth).canonical-message-format.md §6.4 for the
pricing_version + cost_usd (Decimal serialized as string) conventions.analytics-api.md for how curator spend rolls up into
the existing /analytics/cost surface.memory-store.md as the sister “soft-cap → event,
hard-cap → reject” pattern this spec mirrors.skills-archive/), which is reversible by mv.skill.created.source was "auto_generated" or
"curator_generated". Skills installed manually by the user (no
skill.created event, or source="manual") are observed but never
modified.curator.per_run_max_usd,
curator.per_day_max_usd), shared BudgetTracker with the evaluator
(§7). A capped-out run emits a
curator.throttled reason on its summary event and exits without writes.skill.curated
event with the model, cost, latency, before/after skill_version, and
a free-form rationale. No silent state changes.session.ended boundaries when an interval has elapsed; the operator
can also run it explicitly via metis curate <workspace>. The runtime
does not spawn a background thread.curator/state.json per root, not in the skill directory itself.skill.curated(action="suggest")
advisories (deferred to §13.5) but cannot mutate it.metis serve does not run a curator loop
while idle. Schedule the CLI invocation externally if needed (cron,
systemd timer).pattern-store.md §16
if/when an embedding provider is wired.multi-user.md §7 grows an
audit-export surface.skill_load-time validation. The curator is
off-loop maintenance; it does not gate per-call skill loading. A bad
skill still loads via skill_load; the curator notices afterward.skill.created(source="auto_generated")
Phase 2.5 path, separate from the curator. The curator only acts on
skills that already exist.The curator is gated on agent-authored skills landing first (Phase 2.5).
That gate is now satisfied: the skill_save tool and the skill.created
event landed 2026-05-20 (skill-format.md §8.3 /
§9.2). The curator distinguishes “skills it may touch”
from “skills it must not touch” by reading skill.created events from the
trace store:
skill.created.source |
Curator may touch? |
|---|---|
"manual" |
No (read-only) |
"auto_generated" |
Yes |
"imported" |
No (read-only — third-party trust boundary; see §11) |
"curator_generated" |
Yes (new value; introduced by this spec — see §8.1) |
A skill with no skill.created event in the trace (e.g. a SKILL.md the user
hand-rolled before this spec landed) is treated as "manual" — read-only.
Implication for sequencing: building the curator before agent authoring lands would give it nothing to act on. The implementation order is:
skill_save tool + skill.created event with
source="auto_generated" (skill-format.md §8.3;
out of scope for this spec). The skill.created.source enum already
carries "curator_generated" (§8.5), so the curator needs no follow-up
catalog change.| Action | Effect | Auxiliary-model spend? |
|---|---|---|
pin |
Mark the skill pinned (sidecar state). Bypasses all subsequent auto-transitions. Never invoked by the curator itself — set via metis curate pin <name> or the explicit CLI. |
No |
unpin |
Clear the pinned flag. Same surface as pin. |
No |
archive |
Move the skill directory to the archive root (see §5.2). The skill no longer loads. Reversible. | Yes — one auxiliary call to confirm the archive decision against a sample of recent activity. |
restore |
Move an archived skill back to the active root. Reversible. | No — manual only. |
consolidate |
Pick a primary skill from a near-duplicate cluster, archive the others. Sets consolidated_from lineage on the survivor. |
Yes — one auxiliary call to confirm the cluster and pick the survivor. |
edit |
Patch the SKILL.md body in place (frontmatter untouched). Bumps skill_version. The previous body is preserved under the archive root for restoration. |
Yes — one auxiliary call to propose the diff. |
The curator does not invoke any other action. In particular it does not:
skill_id, which is load-bearing for
trace dedup).| Action | Trigger |
|---|---|
archive |
The skill has been inactive (no skill.loaded event with this skill_id) for curator.archive_after_days (default 90) AND is not pinned AND is curator-touchable per §3. Stale (≥ stale_after_days, default 30) is a soft state — see §4.3. |
consolidate |
At least two curator-touchable, non-pinned skills cluster by name + description similarity above the §6 threshold AND have been individually loaded ≥ 1 time in the past archive_after_days. The cluster size is capped at 5 (§13.7). |
edit |
A curator-touchable, non-pinned skill has been loaded ≥ 3 times in the past interval_hours AND its most recent tool.failed events within the same session correlate with skill.loaded of that skill at rate ≥ edit_failure_rate (default 0.5; §13.4). |
pin / unpin / restore |
Manual only (CLI or future UI). Never invoked by the auxiliary model. |
stale_after_days (default 30) is observable but not enforced: a stale
skill is annotated in metis curate list output and in the curator
summary log, but the only action it triggers is bumping its priority in
the §6 candidate selection (stale skills are evaluated for archive sooner
than fresh ones in the same run). Archive happens only at
archive_after_days. This matches Hermes’s “archive is recoverable; the
warning runway is longer than the deadline.”
Per source root, one JSON file:
~/.metis/curator/state.json # global root state
<workspace>/.metis/curator/state.json # workspace root state
Both are optional; a missing file means “no curator state yet” (an empty
{}). Each file has shape:
{
"schema_version": "1",
"last_curation_at_iso": "2026-05-12T18:30:00Z",
"skills": {
"pdf-processing": {
"pinned": true,
"pinned_at_iso": "2026-05-08T10:00:00Z",
"pinned_by": "user",
"origin": "manual",
"consolidated_from": null,
"archive_path": null
},
"code-review-python": {
"pinned": false,
"pinned_at_iso": null,
"pinned_by": null,
"origin": "auto_generated",
"consolidated_from": null,
"archive_path": null
},
"review-python-code": {
"pinned": false,
"pinned_at_iso": null,
"pinned_by": null,
"origin": "curator_generated",
"consolidated_from": ["python-code-review-v1", "code-review-python-old"],
"archive_path": null
},
"old-thing": {
"pinned": false,
"pinned_at_iso": null,
"pinned_by": null,
"origin": "auto_generated",
"consolidated_from": null,
"archive_path": ".metis/skills-archive/old-thing-20260514T183000Z/"
}
}
}
Field semantics:
schema_version — "1". Bumps on a breaking change to this shape.last_curation_at_iso — ISO-8601 UTC timestamp of the last curator
invocation against this root. Drives the §4.2 interval check.skills[name].pinned — bool. The single source of truth for pin
state. Pinned skills bypass every auto-transition.skills[name].origin — derived from the trace store at curator-run
time (the trace store is authoritative; the cache here is for
performance). "manual" / "auto_generated" / "imported" /
"curator_generated" matches skill.created.source plus the new
"curator_generated" value introduced by consolidate.skills[name].consolidated_from — list of skill_ids that were
consolidated into this skill. Only set when origin="curator_generated".skills[name].archive_path — non-null iff the skill is currently
archived; gives the on-disk path of the archived copy relative to the
root. Used by restore to move the directory back.The file is rewritten atomically (write to .tmp, os.replace) on
every mutation. A corrupt file is logged at WARNING and replaced with an
empty state — the underlying ~/.metis/skills/ and
<workspace>/.metis/skills/ directories are authoritative for skill
existence; the sidecar carries only the curator’s annotations.
~/.metis/skills-archive/ # global archive root
<workspace>/.metis/skills-archive/ # workspace archive root
Archived skills live as directories under the archive root, named
<skill_name>-<ISO8601-no-colons>/:
~/.metis/skills-archive/old-thing-20260514T183000Z/
├── SKILL.md
├── scripts/...
└── ...
The full original directory is preserved verbatim — the archive is just
a mv. Restoration is mv back to the active root (and clearing
archive_path in the sidecar). The archive root is not loaded by
load_skills()
because it is at skills-archive/, not skills/. No loader changes
required.
The archive root is append-only by the curator. Manual cleanup
(rm -rf ~/.metis/skills-archive/very-old-thing-*/) is the operator’s
prerogative; the curator never reaps it. This composes with restore:
if the operator manually removed an archived directory, restore fails
with a deterministic error.
Pin state, origin, and lineage live exclusively in the sidecar JSON. The agentskills.io spec is not extended (per the AGENTS.md memory: “conform; don’t invent fields”). A Metis skill remains drop-in loadable in Claude Code / Cursor / Goose; those tools simply won’t know about pin/archive state, which is what we want — the curator is Metis-specific.
A curator run iterates over one root (caller specifies global or
workspace). The selection pipeline:
SkillStore for the root via
load_skills(global_dir=..., workspace_dir=None) or vice versa.
This honors the loader’s existing skip-on-malformed behavior; the
curator never re-validates a skill the loader rejected.skill.created.source is "manual" or "imported"
(or has no skill.created event at all — defaults to manual). Read
the trace store via canonical-message-format.md §9.1.skill.loaded events with this skill_id since
last_curation_at (full-history scan on first run). Aggregate
loads_in_window, last_loaded_at.last_loaded_at is null or older than
archive_after_days.loads_in_window >= 3 AND failure correlation
≥ edit_failure_rate per §4.2.last_loaded_at ascending (stalest first). This biases the
per-run budget toward the highest-value cleanup.curator.per_run_max_usd,
stop. The remaining candidates are not processed this run; they will
show up again on the next eligible run.Run only after the per-skill archive pass. Operates on the non-archived, non-pinned, curator-touchable survivors.
O(n²) over the survivor set; n is bounded by
max_skills_per_root — default 200, hard cap 1000):
a, the, with, for, …), then len(intersection) /
len(shorter) against the (name + description) bag-of-words.
Threshold: name_overlap >= 0.6 OR description_overlap >= 0.7.loads_in_window; the rest split into smaller candidate clusters or
drop out).{action: "consolidate",
survivor: "name", archive: ["name1", "name2"], new_description: "..."}.{action: "skip", reason: "..."}. The cluster
is dropped from this run; will be re-evaluated next run.origin="curator_generated" (if it wasn’t already) and
consolidated_from = [archived skill_ids]. The archived siblings
move to the archive root.Two skill.curated events fire per consolidate: one with action="consolidate"
on the survivor (carrying consolidated_from and skill_version_after),
plus one with action="archive" per archived sibling (no auxiliary spend
on those; the cost was rolled into the survivor’s event).
The edit candidate set is small in practice. For each candidate:
interval_hours window: each skill.loaded(skill_id) event,
find the same session’s subsequent tool.failed events in the
next edit_failure_window_minutes (default 30). Compute the
per-session failure rate.edit_failure_rate (default 0.5) AND loads_in_window >= 3, the
skill is an edit candidate.tool.failed payloads (signatures only, no payloads —
PRIVATE), and the failure rate. The model returns either a unified
diff or {action: "skip", reason: "..."}. A diff must be syntactically
applicable to the current body (whitespace-tolerant, single hunk
≤ 200 lines) — the curator does not retry on a malformed diff..tmp next to the SKILL.md, os.replace). Bump skill_version
(the SHA-256 changes automatically — no curator action needed).
Save the pre-edit body under the archive root as
<workspace>/.metis/skills-archive/<skill_name>-edit-<iso>/SKILL.md
so the edit is recoverable via metis curate restore <skill_name>
--before <iso>. The skill directory in the active root is not
moved.Two caps shared with the evaluator via the same BudgetTracker
primitive:
| Cap | Default | Configurable | Effect |
|---|---|---|---|
curator.per_run_max_usd |
Decimal("0.50") |
yes | When the running spend + next estimate would exceed this, the run stops and emits curator.throttled(reason="per_run_cap"). |
curator.per_day_max_usd |
Decimal("1.00") |
yes | When today’s curator spend has hit this, the next eligible run is skipped entirely (no auxiliary calls). Emits curator.throttled(reason="per_day_cap") and updates last_curation_at so the interval counter advances. |
curator.interval_hours |
168 (7d) |
yes | Minimum gap between auto-runs against the same root. |
curator.stale_after_days |
30 |
yes | Soft state per §4.3. |
curator.archive_after_days |
90 |
yes | Inactivity threshold for the archive action. |
curator.edit_failure_rate |
0.5 |
yes | Per-skill failure rate that qualifies it as an edit candidate. |
curator.max_skills_per_root |
200 |
yes (≤ 1000) | Hard upper bound on candidate-set size; selection truncates. |
These compose with — they do not replace — the evaluator’s caps
(evaluator.md §7). A workspace running both the
evaluator and the curator has 4 independent ceilings; the
BudgetTracker instance is per-(domain, scope), so an exhausted
evaluator budget does not throttle the curator and vice versa.
Before each auxiliary call, the curator estimates the cost using the
existing PriceTable against the candidate body + prompt. Estimates
use the output cap (max tokens reserved for the call), not the
typical output, to avoid an “estimate fits but actual blows” surprise.
If the estimate exceeds the remaining per-run headroom, the candidate
is skipped (not retried with a smaller body in v1; deferred to §13.6).
curator.enabled = false in the workspace config (or
~/.metis/curator/state.json schema-bumped to include a top-level
disabled: true) skips all auto-runs. The explicit CLI
(metis curate <workspace>) still works for one-off operator use.
One new bus catalog event. All payloads are msgspec.Struct(frozen=True)
defined in packages/metis-core/src/metis_core/events/payloads.py.
skill.curatedSensitivity:
user_controlled(floor; downgrades topseudonymousperevent-bus-and-trace-catalog.md §4.4.1whensignals.rationale_redactedis set) Phase: 2.5b Actor: SYSTEM Parent: none (curator runs are not turn-scoped) ORskill.created(when emitted alongside aconsolidatesurvivor)
{
"curator_run_id": str, # monotonic ULID, one per `curator.maybe_run()` invocation
"skill_id": str, # the affected skill
"action": Literal["pin", "unpin", "archive", "restore", "consolidate", "edit"],
"source": Literal["global", "workspace"],
"skill_version_before": str | None, # SHA-256(body)[:16]; null when skill didn't exist before (consolidate survivor created fresh)
"skill_version_after": str | None, # null for archive (skill no longer active)
"consolidated_from": list[str], # populated only when action="consolidate"; empty otherwise
"archive_path": str | None, # populated only when action="archive" or "edit" (pre-edit snapshot); null otherwise
"curator_kind": Literal["llm", "manual"], # llm = auxiliary-model call confirmed; manual = explicit CLI without model call
"curator_model": str | None, # null when curator_kind="manual"
"curator_cost_usd": str, # Decimal serialized as string; "0" when curator_kind="manual"
"curator_pricing_version": str | None,
"curator_latency_ms": int,
"rationale": str, # auxiliary-model rationale; "" for manual actions
"signals": dict, # see §8.2
}
signals dictFree-form key/value pairs the curator attaches for analytics. Standard keys:
| Key | Type | Set when |
|---|---|---|
last_loaded_at_iso |
str \| null |
always; null if the skill was never loaded |
loads_in_window |
int |
always |
inactive_days |
int \| null |
always; null if the skill was never loaded |
rationale_redacted |
bool |
when an operator-configured redactor scrubbed rationale |
cluster_size |
int |
only when action="consolidate" |
failure_rate |
float |
only when action="edit" |
throttled_reason |
str |
only on the run-summary event (see §8.3) |
The free-form shape mirrors evaluator.md §4.4’s
signals design; standard keys are stable, others are advisory.
There is no run-summary skill.curated event (no sentinel
skill_id, no synthetic action). A consumer wanting “all actions for
run X” filters event_type="skill.curated" AND payload.curator_run_id=X.
A run that takes no action (throttled, no candidates, interval not
elapsed, disabled) emits zero skill.curated events; the
“ran-but-did-nothing” case is observable via the run-boundary events
below.
curator.run_started and curator.run_finishedTwo additional bus events for run-boundary observability:
# curator.run_started
{
"curator_run_id": str,
"source": Literal["global", "workspace"],
"trigger": Literal["session_ended", "explicit_cli", "scheduled"],
"candidates_considered": int, # post-filter, pre-budget
"config_snapshot": dict, # the seven §7.1 caps, frozen for this run
}
# curator.run_finished
{
"curator_run_id": str,
"source": Literal["global", "workspace"],
"actions_taken": int,
"spend_usd": str, # Decimal as string
"throttled_reason": Literal["per_run_cap", "per_day_cap", "interval_not_elapsed", "disabled", "no_candidates", "completed"],
"duration_ms": int,
}
Both are pseudonymous (no skill body content; no user message text).
throttled_reason="completed" is the happy path. The four other
non-trivial values (per_run_cap, per_day_cap, interval_not_elapsed,
disabled) cover the “no work happened, and here’s why” cases without
needing a skill.curated event with a sentinel payload.
skill.created value, but…The existing skill.created.source enum in event-bus-and-trace-catalog.md §6.6
must accept "curator_generated" as a value. This is a catalog
change described in the CHANGES.md entry that lands with this spec.
Additive — existing consumers that pattern-match the enum need to
handle the new value or be tolerant; the codebase’s existing usage
treats source as an opaque string in everything except the curator’s
own write path, so the catalog change is non-breaking.
A consolidate that produces a brand-new survivor (no pre-existing
SKILL.md to rewrite) emits one skill.created(source="curator_generated")
followed by one skill.curated(action="consolidate"). A consolidate
that rewrites an existing survivor’s body emits only the
skill.curated(action="consolidate") event (the survivor’s
skill.created was emitted long ago).
One new CLI surface, exposed by apps/cli/src/metis_cli/main.py:
metis curate <workspace> # run once against this workspace's root
metis curate <workspace> --global # run once against the global root (~/.metis/skills/)
metis curate <workspace> --dry-run # plan only; no actions executed
metis curate <workspace> --list # print the active state.json contents (no actions)
metis curate <workspace> pin <skill_name> # manual pin (no auxiliary call)
metis curate <workspace> unpin <skill_name>
metis curate <workspace> restore <skill_name> # manual restore from the archive root
metis curate <workspace> restore <skill_name> --before <iso> # restore a pre-edit snapshot
All commands honor --db-path and --config like the existing
metis evaluate surface. --dry-run and --list emit no
curator.run_started events; the manual subcommands (pin / unpin /
restore) emit skill.curated(curator_kind="manual") events.
A metis curate call with no subcommand against an unconfigured
workspace exits with a deterministic message (“no curator state yet;
run metis curate <workspace> --dry-run first”).
The SessionManager (packages/metis-core/src/metis_core/sessions/manager.py)
gains a curator: SkillCurator | None = None field, defaulting to
None for backwards compatibility. When set and curator.enabled = true,
SessionManager.end_session() calls curator.maybe_run(...) after
emitting session.ended and before returning to the caller. The
curator’s maybe_run is synchronous within the session’s event
loop, bounded by the per-run cost cap.
maybe_run short-circuits cheaply when the interval has not elapsed:
read last_curation_at_iso from the sidecar JSON, compare to now,
return without emitting events if the gap is short. The short-circuit
path is ~ms; the only observable cost on a non-eligible
session.ended is two stat calls and a small JSON read.
The ChatRuntime (apps/cli/src/metis_cli/runtime.py)
wires a SkillCurator instance per workspace by default. Disabling is
via curator.enabled = false in the workspace config (the same config
that drives evaluator caps).
Curator spend rolls up into the existing /analytics/cost endpoint
via a new optional parameter:
GET /analytics/cost?include_curator=true # default: false (preserves existing rollups)
When include_curator=true, the response includes a curator_cost_usd
field per group and at the top level, projected from
skill.curated.curator_cost_usd. The group_by=user|team|gateway_key
projection is not populated for curator spend (the curator is
workspace-scoped, not identity-scoped) — those buckets land under
null. This mirrors the pre-multi-user analytics treatment of
direct-API calls.
A new endpoint, GET /analytics/curator, projects the most useful
curator-specific aggregates:
{
"since": "2026-05-07T00:00:00Z",
"until": "2026-05-14T00:00:00Z",
"runs": 1,
"actions_total": 7,
"actions_by_type": {"archive": 4, "consolidate": 2, "edit": 1},
"spend_usd": "0.184",
"throttled_runs": 0,
"skills_pinned": 3,
"skills_archived": 12,
"skills_active": 27
}
Window parameters and projection conventions match
analytics-api.md §3.
The pattern store (pattern-store.md) is not
modified by this spec. Curator actions do not write pattern fingerprints
or outcomes; the curator and the pattern store are orthogonal feedback
loops over the same trace. A future cross-link (use pattern-store
success scores to inform edit-candidate selection) is out of scope for
v1; see §13.8.
The curator reads eval.completed events when scoring an edit
candidate’s failure correlation (§6.2). The signal is advisory — the
curator’s primary failure signal is tool.failed, not eval.completed.
A workspace with the evaluator disabled still has a working curator;
the edit-candidate-selection pipeline degrades gracefully (falls back
to tool.failed alone).
The curator does not write eval.* events. The evaluator does not
read skill.curated events in v1. The two share only the
BudgetTracker primitive.
archive,
which is a mv. Recoverable for the lifetime of the archive root.skill.created.source ∈ {"manual", "imported"} (or no
skill.created event at all) is read-only. The curator may emit
advisory output about it (§13.5, deferred) but never mutates it.edit is allowed; editing the frontmatter is
not.BudgetTracker.check_and_record.
A capped-out run stops mid-iteration and emits
curator.run_finished(throttled_reason). No silent overage.skill.curated; every run emits exactly one
curator.run_started and one curator.run_finished. Throttled-
before-anything runs emit the two run-boundary events but zero
skill.curated events.metis curate <workspace> restore <skill_name> iff its
archive_path is still present under the archive root. The
operator manually rm -rf-ing the archive forfeits restoration;
the curator does not maintain a second backup.auto_generated and gets consolidated by the curator becomes
curator_generated — still touchable. A user-edited skill that
ever had a manual skill.created event stays manual forever
(until a future operator action explicitly retags it; out of scope
in v1).State + storage:
read → mutate → atomic_write → read.Selection (§6):
skill.created event is filtered out as
origin="manual" (never touched).last_loaded_at reads correctly from the trace store
(skill.loaded event projection).Actions:
archive moves the skill directory to the archive root, updates
the sidecar archive_path, and emits one skill.curated(action="archive").restore moves it back and clears archive_path; emits one
skill.curated(action="restore", curator_kind="manual").consolidate rewrites the survivor’s body via an auxiliary call,
archives the siblings, and emits one
skill.curated(action="consolidate") on the survivor plus one
skill.curated(action="archive") per sibling. The survivor’s
sidecar gains consolidated_from.edit writes a .tmp body and atomically renames; the previous
body lands under the archive root with an -edit-<iso> suffix.pin / unpin update the sidecar and emit
skill.curated(curator_kind="manual"). No auxiliary call.Budget:
curator.per_run_max_usd stops and emits
curator.run_finished(throttled_reason="per_run_cap").curator.per_day_max_usd
short-circuits, updates last_curation_at, and emits
curator.run_finished(throttled_reason="per_day_cap") with zero
actions.curator.enabled = false short-circuits every entry point
(maybe_run, metis curate).Events:
skill.curated event passes the PAYLOAD_REGISTRY validation
(event-bus-and-trace-catalog.md §3.2).curator.run_started and curator.run_finished always come in
pairs with the same curator_run_id.Integration:
SessionManager.end_session() calls curator.maybe_run() exactly
once when interval_hours has elapsed since last_curation_at.metis curate <workspace> --dry-run runs the selection pipeline
but emits no skill.curated events.interval_hours=0) produces zero new actions in the
second run — every candidate was already handled by the first.archive(X) → restore(X) yields a
state byte-identical to the pre-archive state (sidecar, on-disk
directory). Hashed comparison.curator.model?
The Hermes default is “the cheapest model the operator has
configured”; Metis’s bias is similar. Default to
haiku-4-5 in v1; revisit if quality complaints land.metis curate
invocation, or always one at a time? Hermes curates one at a time.
v1 follows; the user can chain (metis curate ws && metis curate ws
--global).pattern-store.md §16 embedding provider would
trivially upgrade clustering quality. Decision deferred — only
matters once an embedding provider is wired.edit_failure_rate calibration. 0.5 is a guess. Tune against
the benchmark suite once we have ≥ 10 auto-generated skills and
real failure rates.skill.curated(action="suggest") that
surfaces in /analytics/curator would let the user act on
curator-found patches without surrendering authority. Deferred to
v2.success_score in the pattern store could be
an edit candidate even without tool.failed signals. Cross-link
deferred to v2.multi-user.md §7.3),
curator actions need to land in the audit export with full
rationale (PII-redaction respected). Deferred to multi-user §7.--auto-approve for explicit metis curate. v1 always
runs without confirmation prompts (it is the operator running the
CLI). If we add a “confirm each action” mode later, the toggle
follows the existing --auto-allow convention for
metis dev / metis tui.| Date | Decision | Rationale |
|---|---|---|
| 2026-05-14 | Adopt Hermes’s six actions (pin / unpin / archive / restore / consolidate / edit) | Survey of agent/curator.py showed this set covers the empirical failure modes; no obvious omission in 8 weeks of real-world activity. |
| 2026-05-14 | Archive = mv to sibling directory, never delete |
Reversibility is load-bearing for owner trust; storage is cheap. |
| 2026-05-14 | Sidecar JSON for state, not SKILL.md frontmatter | Preserves agentskills.io conformance; the AGENTS.md memory pin on “conform; don’t invent fields” is explicit. |
| 2026-05-14 | Inactivity-triggered at session.ended, not a daemon |
Metis is local-first and often not running. A background thread that wakes only when metis chat is open and idle would never wake. Session boundary is the natural firing edge. |
| 2026-05-14 | Share BudgetTracker with the evaluator; separate caps |
Code reuse without coupling the budgets; an exhausted evaluator does not throttle the curator. |
| 2026-05-14 | One skill.curated event per action; no sentinel run-summary event |
Run-summary fits naturally in curator.run_started + curator.run_finished envelope; sentinel events with bogus skill_ids are ugly. |
| 2026-05-14 | Curator-touchable scope: auto_generated and curator_generated only |
A user who put a SKILL.md on disk owns it. The curator’s authority extends only to what the agent (or the curator itself) created. |
| 2026-05-14 | Default interval_hours=168, archive_after_days=90 (match Hermes) |
These are the only values with empirical evidence behind them. Tighten if observed action rates are too low. |
| 2026-05-14 | Defer embedding clustering to v2 | Substring overlap is the simplest thing that could work; embedding clustering composes cleanly with pattern-store.md §16 if/when it lands. |
agent/curator.py — the design lifted here.skill-format.md — the Skill / SkillStore substrate.event-bus-and-trace-catalog.md §6.6 — the skill.* event family this spec extends.evaluator.md §7 — the BudgetTracker primitive shared with the curator.canonical-message-format.md §6.4 — cost_usd / pricing_version conventions.analytics-api.md — the surface /analytics/curator joins.memory-store.md — sister “soft / hard cap → event / reject” pattern.multi-user.md §7.3 — future audit-export integration (deferred).pattern-store.md §16 — future embedding-clustering integration (deferred).