/** * Sentient SKILLS CURATOR — automatic lifecycle transitions for agent-created skills. * * @remarks * Pure transition state machine that walks the per-user `skills.db` registry * (`source_type='agent-created'` / `'user'`) and moves rows between the three * lifecycle states defined by {@link SkillLifecycleState}: * * ``` * ┌──────────────┐ anchor <= stale ┌──────────────┐ * │ active │ ───────────────────────▶│ stale │ * │ │ ◀───────────────────────│ │ * └──────────────┘ anchor > stale └──────────────┘ * │ │ * └──── anchor <= archive ───────────────────┤ * ▼ * ┌──────────────┐ * │ archived │ * │ (on-disk │ * │ moved → │ * │ .archive/) │ * └──────────────┘ * ``` * * The cutoffs are configured via `daemon.curator.staleAfterDays` and * `daemon.curator.archiveAfterDays` in `~/.cleo/config.json` (defaults: 30 and * 90 days respectively). * * ## Triple guard (MANDATORY before any archive) * * Before writing to disk OR mutating any row, every candidate is screened by * THREE conditions, ALL of which must pass: * * 1. {@link is_canonical}(`installPath`, `{dbSourceType, manifestNames}`) === `false` * 2. `row.pinned === false` * 3. `row.sourceType !== 'canonical'` * * If any guard fails, the row is skipped entirely (no transition, no write). * * ## Archive-only — NEVER delete * * Archiving moves the on-disk skill directory from `//` to * `/.archive/-/` using `fs.cpSync` followed by * `fs.rmSync` of the original. The destination is timestamped so multiple * archives of the same name do not collide. The owning row's * `lifecycle_state` is then flipped to `'archived'` and `archived_at` + * `archived_from_path` are populated so `cleo skill restore` is reproducible. * * Deletion is NEVER an option — archives are recoverable, deletes are not. * * ## Opt-in * * The curator is OFF by default. The daemon integration in `daemon.ts` reads * `daemon.curator.enabled` and only schedules ticks when set to `true`. * Honors `cleo sentient kill` like every other sentient subsystem. * * @see {@link /mnt/projects/hermes-agent/agent/curator.py} lines 256-296 — original Hermes transitions * @see {@link docs/architecture/SG-CLEO-SKILLS-architecture-v3.md} §6 — is_canonical resolution * @task T9562, T9677 * @epic T9562 * @saga T9560 */ import type { SkillLifecycleState, SkillRow, SkillSourceType } from '../store/schema/skills-schema.js'; /** Default — agent-created skills go stale after 30 days of no activity. */ export declare const DEFAULT_STALE_AFTER_DAYS = 30; /** Default — agent-created skills are archived after 90 days of no activity. */ export declare const DEFAULT_ARCHIVE_AFTER_DAYS = 90; /** Default — curator review interval (7 days). */ export declare const DEFAULT_RUN_EVERY_HOURS: number; /** * Source types the curator is allowed to inspect. * * @remarks * `'canonical'` is explicitly EXCLUDED — Sphere A canonical skills are * read-only on user machines per architecture v3 §6. */ export declare const CURATABLE_SOURCE_TYPES: readonly SkillSourceType[]; /** * Reason a transition was skipped (non-mutating outcome). * * @public */ export type SkipReason = 'pinned' | 'canonical' | 'no-transition-needed' | 'install-path-missing'; /** * Kind of a curator decision — drives the visit log produced by * {@link runCuratorTick}. * * @public */ export type TransitionKind = 'mark-stale' | 'reactivate' | 'archive' | 'skip'; /** * A single decision produced by {@link runCuratorTick} for one skill row. * * @public */ export interface Transition { /** Skill identifier (matches `skills.name`). */ readonly name: string; /** Lifecycle state of the row before the transition was applied. */ readonly fromState: SkillLifecycleState; /** Lifecycle state the row will be in after a successful apply. */ readonly toState: SkillLifecycleState; /** Decision kind. */ readonly kind: TransitionKind; /** Populated only when `kind === 'skip'` — explains why. */ readonly skipReason?: SkipReason; /** * Resolved on-disk path of the skill, captured BEFORE archive moves the * directory. Mirrors `skills.install_path`. */ readonly installPath: string; /** * Activity anchor used to make the decision (the latest of * `last_updated_at` and `installed_at`, falling back to "now"). ISO-8601 UTC. */ readonly anchorAt: string; } /** * Options accepted by {@link runCuratorTick}. * * @public */ export interface RunCuratorTickOptions { /** * When `true`, transitions are computed but NOT applied — no disk moves and * no db writes. The returned visit log still describes what WOULD happen. * * @defaultValue `false` */ dryRun?: boolean; /** * Override "now". Tests use this so cutoffs are deterministic. * * @defaultValue new Date() */ now?: Date; /** * Override the days threshold for the `active → stale` transition. * * @defaultValue {@link DEFAULT_STALE_AFTER_DAYS} */ staleAfterDays?: number; /** * Override the days threshold for the `* → archived` transition. * * @defaultValue {@link DEFAULT_ARCHIVE_AFTER_DAYS} */ archiveAfterDays?: number; /** * Override the canonical-name manifest used by the triple guard. Production * callers should pass the contents of `packages/skills/skills/manifest.json` * so the manifest-membership branch of `is_canonical` is meaningful. * * @defaultValue `undefined` */ manifestNames?: readonly string[]; /** * Override the skills root directory used to compute the archive * destination. Defaults to {@link resolveSkillsRoot}. Tests pass a tmpfs path * so disk moves stay sandboxed. * * @defaultValue {@link resolveSkillsRoot}() */ skillsRoot?: string; } /** * Summary of a curator tick (for telemetry / status output). * * @public */ export interface CuratorTickSummary { /** Total number of rows inspected. */ readonly checked: number; /** Number of `active → stale` flips. */ readonly markedStale: number; /** Number of `stale → active` reactivations. */ readonly reactivated: number; /** Number of `* → archived` moves applied to disk. */ readonly archived: number; /** Number of rows skipped by the triple guard or "no transition needed". */ readonly skipped: number; /** Whether this run was a dry-run (no disk writes). */ readonly dryRun: boolean; /** ISO-8601 timestamp captured at the start of the tick (UTC). */ readonly startedAt: string; /** ISO-8601 timestamp captured at the end of the tick (UTC). */ readonly completedAt: string; } /** * Combined return type of {@link runCuratorTick} — visit log + summary. * * @public */ export interface CuratorTickResult { /** One {@link Transition} per row inspected, in `name`-sorted order. */ readonly transitions: readonly Transition[]; /** Aggregated counters describing what changed. */ readonly summary: CuratorTickSummary; } /** * Options accepted by {@link applyTransition}. * * @public */ export interface ApplyTransitionOptions { /** * Override the skills root directory used as the archive destination prefix. * * @defaultValue {@link resolveSkillsRoot}() */ skillsRoot?: string; /** * Override "now" used to stamp `archived_at` and the archive folder suffix. * * @defaultValue new Date() */ now?: Date; } /** * Result of {@link applyTransition} — captures the new on-disk path and the * mutated row. * * @public */ export interface ApplyTransitionResult { /** The transition that was applied. */ readonly transition: Transition; /** Resolved row after the upsert. */ readonly row: SkillRow; /** Destination path when `kind === 'archive'`; otherwise `null`. */ readonly archiveDestination: string | null; } /** * Apply a single {@link Transition} produced by {@link runCuratorTick}. * * @remarks * For `mark-stale` and `reactivate` this is a plain db row flip. For * `archive` the on-disk skill directory is moved into * `/.archive/-/` (cp then rm — never `rename`, * because the archive lives under the same root) and the row is updated with * `lifecycle_state='archived'`, `archived_at`, and `archived_from_path`. * * The `skip` kind is a no-op — supplied so callers can iterate the full * visit log without branching on kind. * * Callers MUST have already verified the triple guard for this row. This * function does NOT re-check the guard so dry-run / live runs share the * decide-once-apply-many contract. * * @param transition - The decision to apply. * @param options - Optional overrides for archive destination + timestamp. * @returns The mutated row plus the archive destination (if any). * * @throws {Error} If the install_path does not resolve to a directory we can * read — the curator never attempts to write to disk without the source * being present. * * @public */ export declare function applyTransition(transition: Transition, options?: ApplyTransitionOptions): Promise; /** * Run one curator pass. * * @remarks * Visits every row in `skills` whose `source_type` is one of * {@link CURATABLE_SOURCE_TYPES}, applies the {@link tripleGuard}, decides a * {@link Transition}, and (unless `dryRun` is set) applies it via * {@link applyTransition}. * * The return shape lets callers (CLI / daemon) decouple "what would change" * from "what changed" — a dry-run returns the same visit log a live run * does, minus the side-effects. * * @param opts - Options controlling cutoffs, "now", and dry-run behaviour. * @returns A {@link CuratorTickResult} containing the visit log + summary. * * @example * ```typescript * // Live run with defaults * const result = await runCuratorTick(); * console.log(`archived ${result.summary.archived} skill(s)`); * * // Dry-run preview from CLI * const preview = await runCuratorTick({ dryRun: true }); * for (const t of preview.transitions) { * console.log(`${t.kind} ${t.name} ${t.fromState} → ${t.toState}`); * } * ``` * * @public */ export declare function runCuratorTick(opts?: RunCuratorTickOptions): Promise; /** * Result of {@link restoreSkillFromArchive}. * * @public */ export interface RestoreSkillResult { /** Skill name that was restored. */ readonly name: string; /** Absolute path the skill now lives at. */ readonly restoredTo: string; /** Absolute path the archive was read from. */ readonly restoredFrom: string; /** ISO-8601 timestamp of the restore (UTC). */ readonly restoredAt: string; } /** * Restore a previously-archived skill back to the live skills root. * * @remarks * Mirror of the archive logic: copy the most recent * `/.archive/-/` directory to * `//`, then update the row so `lifecycle_state='active'` * and `archived_at` / `archived_from_path` are cleared. * * Selection rule when multiple archives exist for the same name: the most * recent one (largest `ts` suffix) wins. Older archives are left in place * so the operator can roll forward manually if needed. * * @param name - Skill identifier to restore. * @param options - Optional overrides for skills root + "now". * @returns The {@link RestoreSkillResult} describing the restored location. * * @throws {Error} If no archive exists for the given name. * @throws {Error} If the live install path already exists (refuse-to-clobber). * * @public */ export declare function restoreSkillFromArchive(name: string, options?: ApplyTransitionOptions): Promise; /** * Reset internal module state — exposed exclusively for tests that need to * close the skills.db singleton between cases. * * @internal */ export declare function __resetCuratorForTest(): void; //# sourceMappingURL=curator.d.ts.map