diff --git a/docs/design/2026-07-27-auto-skill-curator.md b/docs/design/2026-07-27-auto-skill-curator.md new file mode 100644 index 00000000000..be98ca25e79 --- /dev/null +++ b/docs/design/2026-07-27-auto-skill-curator.md @@ -0,0 +1,117 @@ +# Auto-Skill Curator + +## Problem + +Qwen Code can extract reusable project skills from tool-heavy conversations, +but accepted auto-skills only accumulate. The existing review agent can create +or update `source: auto-skill` skills and is explicitly forbidden from deleting +them. Path gating and `skills.disabled` reduce prompt noise but do not maintain +the on-disk library. + +## Scope + +Add a small, deterministic lifecycle manager for project auto-skills: + +- Track successful invocations of project skills whose directory starts with + `auto-skill-` and whose frontmatter contains `source: auto-skill`. +- Mark a managed skill stale after 30 days without activity. +- Archive it after 90 days without activity by moving its whole directory out + of `.qwen/skills/` into `.qwen/archived-skills/`. +- Allow individual managed skills to be pinned out of automatic transitions. +- Run the deterministic pass at most once every 7 days during configuration + initialization when Auto Skill is enabled and the workspace is trusted. +- Expose `/curator`, `/curator status`, `/curator run [--dry-run]`, and + `/curator pin|unpin|restore ` in interactive, non-interactive, + and ACP command surfaces. + +This first version does not use an LLM, consolidate overlapping skills, manage +personal/bundled/extension/learned/hand-authored skills, permanently delete +anything, or introduce configurable thresholds. + +## Ownership and persistence + +The curator is resolved only from `Config.getProjectRoot()`. Its state lives at +`/.qwen/skill-curator.json`, and archived packages live at +`/.qwen/archived-skills/`. There is no fallback to the process's +primary workspace, home directory, or another active session. This keeps +daemon and multi-workspace sessions isolated. + +State is keyed by the auto-skill directory name because that is the unit moved +to and from the archive. Each record stores the frontmatter skill name, +first-seen time, last successful use, use count, lifecycle state, pin state, +and optional archive time. Writes are serialized with a cross-process lock and +committed atomically. + +Corrupt state is a hard, non-mutating failure. The curator must not infer that +missing usage means inactivity when its persisted evidence cannot be read. + +## Eligibility and safety + +A directory is curator-managed only when every condition holds: + +1. It is a direct, non-symlink directory under the project skills root. +2. Its name starts with `auto-skill-`. +3. It contains a regular, non-symlink `SKILL.md`. +4. The opening YAML frontmatter contains exactly `source: auto-skill`. + +This double marker prevents the curator from moving hand-authored, learned, +extension, bundled, personal, malformed, or symlinked content. Archive and +restore never overwrite an existing skill. A destination collision skips only +that package so unrelated maintenance can continue. Archived directory names +are shown as reserved in the review prompt and rejected by its write permission +guard, while confirmation staging still snapshots active skills only. +If state persistence fails after moves, the pass attempts to move every package +back before surfacing the error. + +Read-only status and dry-run previews remain available in safe mode and +untrusted workspaces. Applying a maintenance pass, pinning, unpinning, and +restoring require a trusted workspace outside safe mode. + +## Activity and transitions + +A successful Skill tool or direct skill slash-command invocation updates an +eligible auto-skill record best-effort, even while automatic skill generation +is disabled. This keeps observed activity independent from the switch that +controls generation and scheduled maintenance. Failed, skill-disabled, or +hook-blocked invocations do not count. + +For a live skill, activity is the newest of: + +- the persisted last successful invocation; +- the persisted first-seen time; +- the persisted restore time; and +- the skill manifest modification time. + +Including modification time prevents a recently improved skill from being +archived merely because it has not yet been invoked again. + +The first observation of each eligible skill seeds `firstSeenAt = now` rather +than inferring inactivity from an old filesystem timestamp. The first automatic +observation also seeds `lastRunAt`, then waits a full 7-day interval. Explicit +`/curator run` bypasses the interval but preserves per-skill first-sight grace; +`--dry-run` reports the same seeding and transition candidates without moving +directories or changing state. Pinned records bypass stale and archive +transitions until explicitly unpinned. + +## Integration points + +- `Config.initialize`: performs the due deterministic pass before + `SkillManager` scans the filesystem. +- `SkillTool`: records a successful managed-skill invocation. +- `SkillCommandLoader` and the interactive/non-interactive command processors: + record successful direct slash-command invocations; ACP reuses the + non-interactive processor. +- `SkillManager`: its existing refresh path is used after manual archive or + restore so the model and slash-command surfaces immediately match disk. +- `BuiltinCommandLoader`: publishes the new `/curator` command. + +No other consumer should write curator state or move managed skill packages. + +## Verification + +Unit tests cover eligibility, first-run seeding, stale/archive thresholds, +dry-run non-mutation, recent-use protection, recently-modified protection, +corrupt-state fail-closed behavior, collision handling, restoration, and the +command surface. Existing Skill tool tests verify that only successful loads +record usage. Build and typecheck cover the cross-package export and command +registration. diff --git a/docs/users/features/commands.md b/docs/users/features/commands.md index ecb9ca29f3a..b8429fae648 100644 --- a/docs/users/features/commands.md +++ b/docs/users/features/commands.md @@ -83,6 +83,7 @@ Commands for managing AI tools and models. | `/import-config` | Import MCP servers from Claude configs | `/import-config all`, `/import-config claude-code`, `/import-config claude-desktop --scope user\|project` | | `/tools` | Display currently available tool list | `/tools`, `/tools desc` | | `/skills` | Open the Skills panel to browse, search, toggle, and launch skills | `/skills`, `/` | +| `/curator` | Inspect, pin, archive, or restore inactive project auto-skills | `/curator`, `/curator run --dry-run`, `/curator pin `, `/curator restore ` | | `/plan` | Switch to plan mode or exit plan mode | `/plan`, `/plan `, `/plan exit` | | `/approval-mode` | Change the tool-approval mode (current session only) | `/approval-mode`, `/approval-mode auto-edit` | | → `plan` | Analysis only, no execution (secure review) | `/approval-mode plan` | diff --git a/docs/users/features/skills.md b/docs/users/features/skills.md index f84f6805122..591196f1991 100644 --- a/docs/users/features/skills.md +++ b/docs/users/features/skills.md @@ -68,6 +68,20 @@ Use project Skills for: Project Skills can be checked into git and automatically become available to teammates. +### Maintain auto-generated project Skills + +Qwen Code tracks successful uses of generated project Skills locally, including while new Auto Skill generation is disabled, so re-enabling maintenance cannot mistake a recently used skill for an inactive one. When **Auto Skill** is enabled, it periodically moves inactive generated Skills out of the active library. Only directories named `.qwen/skills/auto-skill-*` whose `SKILL.md` frontmatter contains `source: auto-skill` are managed; personal, extension, bundled, and hand-authored Skills are never selected. + +- After 30 days without a successful use or `SKILL.md` edit, an auto-skill is marked stale. +- After 90 days, its complete directory is moved to `.qwen/archived-skills/`. Nothing is permanently deleted. +- Automatic maintenance runs at most once every 7 days in trusted workspaces. Each newly observed auto-skill gets a full grace period before maintenance begins. +- A pinned auto-skill is excluded from automatic stale and archive transitions until it is unpinned. +- Archived directory names remain reserved, and an existing archive destination skips only that collision rather than stopping maintenance for other skills. + +Use `/curator` to see active, stale, archived, and pinned auto-skills. Run `/curator run --dry-run` to preview a maintenance pass, `/curator run` to apply it immediately, `/curator pin ` or `/curator unpin ` to control per-skill maintenance, or `/curator restore ` to move an archived auto-skill back into the active library. + +Status and dry-run previews are available in safe mode and untrusted workspaces. Applying maintenance, changing pins, and restoring archived auto-skills require a trusted workspace outside safe mode. + ## Write `SKILL.md` Create a `SKILL.md` file with YAML frontmatter and Markdown content: diff --git a/packages/cli/src/i18n/locales/ca.js b/packages/cli/src/i18n/locales/ca.js index e08e872d6d0..3f12283ad3e 100644 --- a/packages/cli/src/i18n/locales/ca.js +++ b/packages/cli/src/i18n/locales/ca.js @@ -2758,4 +2758,63 @@ export default { 'Executant aquesta tasca programada en una sessió nova: {{link}}', 'This scheduled run could not be started: {{error}}': "Aquesta execució programada no s'ha pogut iniciar: {{error}}", + + // ========================================================================== + // Auto-skill curator (/curator command) + // ========================================================================== + 'Maintain project auto-skills based on recent use.': + "Gestiona les habilitats automàtiques del projecte segons l'ús recent.", + 'Show project auto-skill lifecycle status.': + 'Mostra l’estat del cicle de vida de les habilitats automàtiques del projecte.', + 'Run project auto-skill lifecycle maintenance.': + 'Executa el manteniment del cicle de vida de les habilitats automàtiques del projecte.', + 'Restore an archived project auto-skill.': + 'Restaura una habilitat automàtica del projecte arxivada.', + 'Auto-skill curator': "Gestor d'habilitats automàtiques", + 'Last run: {{time}}': 'Última execució: {{time}}', + 'Active: {{count}}': 'Actives: {{count}}', + 'Stale: {{count}}': 'Obsoletes: {{count}}', + 'Archived: {{count}}': 'Arxivades: {{count}}', + 'Stale skills:': 'Habilitats obsoletes:', + 'Pinned skills:': 'Habilitats fixades:', + 'Archived skills:': 'Habilitats arxivades:', + 'Dry run complete.': 'Simulació completada.', + 'Curator run complete.': 'Execució del gestor completada.', + 'Checked: {{count}}': 'Comprovades: {{count}}', + 'First observed: {{count}}': 'Observades per primera vegada: {{count}}', + 'Marked stale: {{count}}': 'Marcades com a obsoletes: {{count}}', + 'Reactivated: {{count}}': 'Reactivades: {{count}}', + 'Skipped archive collisions: {{count}}': + "Col·lisions d'arxivament omeses: {{count}}", + 'Archive candidates:': "Candidates a l'arxivament:", + 'Skipped archive collisions:': "Col·lisions d'arxivament omeses:", + 'Skipped rename errors: {{count}}': + 'Errors de canvi de nom omesos: {{count}}', + 'Skipped rename errors:': 'Errors de canvi de nom omesos:', + '{{verb}}: {{count}}': '{{verb}}: {{count}}', + 'Would archive': "S'arxivarien", + Archived: 'Arxivades', + 'Failed to read auto-skill curator status: {{message}}': + "No s'ha pogut llegir l'estat del gestor d'habilitats automàtiques: {{message}}", + 'Usage: /curator run [--dry-run]': 'Ús: /curator run [--dry-run]', + 'Failed to run auto-skill curator: {{message}}': + "No s'ha pogut executar el gestor d'habilitats automàtiques: {{message}}", + 'Usage: /curator restore ': 'Ús: /curator restore ', + 'Restored auto-skill: {{name}}': 'Habilitat automàtica restaurada: {{name}}', + 'Failed to restore auto-skill: {{message}}': + "No s'ha pogut restaurar l'habilitat automàtica: {{message}}", + 'Exclude an auto-skill from automatic maintenance.': + 'Exclou una habilitat automàtica del manteniment automàtic.', + 'Return a pinned auto-skill to automatic maintenance.': + 'Retorna una habilitat automàtica fixada al manteniment automàtic.', + 'Usage: /curator pin ': 'Ús: /curator pin ', + 'Usage: /curator unpin ': 'Ús: /curator unpin ', + 'Pinned auto-skill: {{name}}': 'Habilitat automàtica fixada: {{name}}', + 'Unpinned auto-skill: {{name}}': 'Habilitat automàtica desfixada: {{name}}', + 'Failed to update auto-skill pin: {{message}}': + "No s'ha pogut actualitzar la fixació de l'habilitat automàtica: {{message}}", + 'Auto-skill curator changes are disabled in safe mode.': + "Els canvis del gestor d'habilitats automàtiques estan desactivats en mode segur.", + 'Auto-skill curator changes are only available in trusted workspaces. Trust this folder via `/trust` and try again.': + "Els canvis del gestor d'habilitats automàtiques només estan disponibles en espais de treball de confiança. Marca aquesta carpeta com a fiable amb `/trust` i torna-ho a provar.", }; diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index 62a1bea130f..49c706e84f4 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -2238,4 +2238,65 @@ export default { 'Die Sitzungsaufzeichnung wurde nach einem Schreibfehler beendet. Neue Nachrichten der betroffenen Sitzung werden nicht gespeichert. Prüfen Sie Speicherplatz und Berechtigungen und starten Sie anschließend eine neue Sitzung, um die Aufzeichnung fortzusetzen. Weitere Details finden Sie im Debug-Protokoll.', 'Session recording stopped after a write failure. New messages for the affected session will not be saved. Check disk space and permissions, then run `/clear` to start a new recorded session. See the debug log for details.': 'Die Sitzungsaufzeichnung wurde nach einem Schreibfehler beendet. Neue Nachrichten der betroffenen Sitzung werden nicht gespeichert. Prüfen Sie Speicherplatz und Berechtigungen und führen Sie anschließend `/clear` aus, um eine neue aufgezeichnete Sitzung zu starten. Weitere Details finden Sie im Debug-Protokoll.', + + // ========================================================================== + // Auto-skill curator (/curator command) + // ========================================================================== + 'Maintain project auto-skills based on recent use.': + 'Projekt-Auto-Skills anhand der letzten Nutzung verwalten.', + 'Show project auto-skill lifecycle status.': + 'Lebenszyklusstatus der Projekt-Auto-Skills anzeigen.', + 'Run project auto-skill lifecycle maintenance.': + 'Lebenszykluswartung der Projekt-Auto-Skills ausführen.', + 'Restore an archived project auto-skill.': + 'Einen archivierten Projekt-Auto-Skill wiederherstellen.', + 'Auto-skill curator': 'Auto-Skill-Kurator', + 'Last run: {{time}}': 'Letzter Durchlauf: {{time}}', + 'Active: {{count}}': 'Aktiv: {{count}}', + 'Stale: {{count}}': 'Veraltet: {{count}}', + 'Archived: {{count}}': 'Archiviert: {{count}}', + 'Stale skills:': 'Veraltete Skills:', + 'Pinned skills:': 'Fixierte Skills:', + 'Archived skills:': 'Archivierte Skills:', + 'Dry run complete.': 'Testlauf abgeschlossen.', + 'Curator run complete.': 'Kurator-Durchlauf abgeschlossen.', + 'Checked: {{count}}': 'Geprüft: {{count}}', + 'First observed: {{count}}': 'Erstmals erfasst: {{count}}', + 'Marked stale: {{count}}': 'Als veraltet markiert: {{count}}', + 'Reactivated: {{count}}': 'Reaktiviert: {{count}}', + 'Skipped archive collisions: {{count}}': + 'Übersprungene Archivierungskonflikte: {{count}}', + 'Archive candidates:': 'Archivierungskandidaten:', + 'Skipped archive collisions:': 'Übersprungene Archivierungskonflikte:', + 'Skipped rename errors: {{count}}': + 'Übersprungene Umbenennungsfehler: {{count}}', + 'Skipped rename errors:': 'Übersprungene Umbenennungsfehler:', + '{{verb}}: {{count}}': '{{verb}}: {{count}}', + 'Would archive': 'Würde archivieren', + Archived: 'Archiviert', + 'Failed to read auto-skill curator status: {{message}}': + 'Status des Auto-Skill-Kurators konnte nicht gelesen werden: {{message}}', + 'Usage: /curator run [--dry-run]': 'Verwendung: /curator run [--dry-run]', + 'Failed to run auto-skill curator: {{message}}': + 'Auto-Skill-Kurator konnte nicht ausgeführt werden: {{message}}', + 'Usage: /curator restore ': + 'Verwendung: /curator restore ', + 'Restored auto-skill: {{name}}': 'Auto-Skill wiederhergestellt: {{name}}', + 'Failed to restore auto-skill: {{message}}': + 'Auto-Skill konnte nicht wiederhergestellt werden: {{message}}', + 'Exclude an auto-skill from automatic maintenance.': + 'Einen Auto-Skill von der automatischen Wartung ausschließen.', + 'Return a pinned auto-skill to automatic maintenance.': + 'Einen fixierten Auto-Skill wieder automatisch warten.', + 'Usage: /curator pin ': 'Verwendung: /curator pin ', + 'Usage: /curator unpin ': + 'Verwendung: /curator unpin ', + 'Pinned auto-skill: {{name}}': 'Auto-Skill fixiert: {{name}}', + 'Unpinned auto-skill: {{name}}': 'Fixierung aufgehoben: {{name}}', + 'Failed to update auto-skill pin: {{message}}': + 'Fixierung des Auto-Skills konnte nicht aktualisiert werden: {{message}}', + 'Auto-skill curator changes are disabled in safe mode.': + 'Änderungen durch den Auto-Skill-Kurator sind im Sicherheitsmodus deaktiviert.', + 'Auto-skill curator changes are only available in trusted workspaces. Trust this folder via `/trust` and try again.': + 'Änderungen durch den Auto-Skill-Kurator sind nur in vertrauenswürdigen Arbeitsbereichen verfügbar. Stufen Sie diesen Ordner mit `/trust` als vertrauenswürdig ein und versuchen Sie es erneut.', }; diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index d646be5ff00..2b6f33ad5d6 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -2736,4 +2736,62 @@ export default { 'Session recording stopped after a write failure. New messages for the affected session will not be saved. Check disk space and permissions, then start a new session to resume recording. See the debug log for details.', 'Session recording stopped after a write failure. New messages for the affected session will not be saved. Check disk space and permissions, then run `/clear` to start a new recorded session. See the debug log for details.': 'Session recording stopped after a write failure. New messages for the affected session will not be saved. Check disk space and permissions, then run `/clear` to start a new recorded session. See the debug log for details.', + + // ========================================================================== + // Auto-skill curator (/curator command) + // ========================================================================== + 'Maintain project auto-skills based on recent use.': + 'Maintain project auto-skills based on recent use.', + 'Show project auto-skill lifecycle status.': + 'Show project auto-skill lifecycle status.', + 'Run project auto-skill lifecycle maintenance.': + 'Run project auto-skill lifecycle maintenance.', + 'Restore an archived project auto-skill.': + 'Restore an archived project auto-skill.', + 'Auto-skill curator': 'Auto-skill curator', + 'Last run: {{time}}': 'Last run: {{time}}', + 'Active: {{count}}': 'Active: {{count}}', + 'Stale: {{count}}': 'Stale: {{count}}', + 'Archived: {{count}}': 'Archived: {{count}}', + 'Stale skills:': 'Stale skills:', + 'Pinned skills:': 'Pinned skills:', + 'Archived skills:': 'Archived skills:', + 'Dry run complete.': 'Dry run complete.', + 'Curator run complete.': 'Curator run complete.', + 'Checked: {{count}}': 'Checked: {{count}}', + 'First observed: {{count}}': 'First observed: {{count}}', + 'Marked stale: {{count}}': 'Marked stale: {{count}}', + 'Reactivated: {{count}}': 'Reactivated: {{count}}', + 'Skipped archive collisions: {{count}}': + 'Skipped archive collisions: {{count}}', + 'Archive candidates:': 'Archive candidates:', + 'Skipped archive collisions:': 'Skipped archive collisions:', + 'Skipped rename errors: {{count}}': 'Skipped rename errors: {{count}}', + 'Skipped rename errors:': 'Skipped rename errors:', + '{{verb}}: {{count}}': '{{verb}}: {{count}}', + 'Would archive': 'Would archive', + Archived: 'Archived', + 'Failed to read auto-skill curator status: {{message}}': + 'Failed to read auto-skill curator status: {{message}}', + 'Usage: /curator run [--dry-run]': 'Usage: /curator run [--dry-run]', + 'Failed to run auto-skill curator: {{message}}': + 'Failed to run auto-skill curator: {{message}}', + 'Usage: /curator restore ': 'Usage: /curator restore ', + 'Restored auto-skill: {{name}}': 'Restored auto-skill: {{name}}', + 'Failed to restore auto-skill: {{message}}': + 'Failed to restore auto-skill: {{message}}', + 'Exclude an auto-skill from automatic maintenance.': + 'Exclude an auto-skill from automatic maintenance.', + 'Return a pinned auto-skill to automatic maintenance.': + 'Return a pinned auto-skill to automatic maintenance.', + 'Usage: /curator pin ': 'Usage: /curator pin ', + 'Usage: /curator unpin ': 'Usage: /curator unpin ', + 'Pinned auto-skill: {{name}}': 'Pinned auto-skill: {{name}}', + 'Unpinned auto-skill: {{name}}': 'Unpinned auto-skill: {{name}}', + 'Failed to update auto-skill pin: {{message}}': + 'Failed to update auto-skill pin: {{message}}', + 'Auto-skill curator changes are disabled in safe mode.': + 'Auto-skill curator changes are disabled in safe mode.', + 'Auto-skill curator changes are only available in trusted workspaces. Trust this folder via `/trust` and try again.': + 'Auto-skill curator changes are only available in trusted workspaces. Trust this folder via `/trust` and try again.', }; diff --git a/packages/cli/src/i18n/locales/fr.js b/packages/cli/src/i18n/locales/fr.js index a7b79b52fea..f06aa98a2db 100644 --- a/packages/cli/src/i18n/locales/fr.js +++ b/packages/cli/src/i18n/locales/fr.js @@ -2241,4 +2241,67 @@ export default { "L'enregistrement de la session s'est arrêté après un échec d'écriture. Les nouveaux messages de la session concernée ne seront pas enregistrés. Vérifiez l'espace disque et les autorisations, puis démarrez une nouvelle session pour reprendre l'enregistrement. Consultez le journal de débogage pour plus de détails.", 'Session recording stopped after a write failure. New messages for the affected session will not be saved. Check disk space and permissions, then run `/clear` to start a new recorded session. See the debug log for details.': "L'enregistrement de la session s'est arrêté après un échec d'écriture. Les nouveaux messages de la session concernée ne seront pas enregistrés. Vérifiez l'espace disque et les autorisations, puis exécutez `/clear` pour démarrer une nouvelle session enregistrée. Consultez le journal de débogage pour plus de détails.", + + // ========================================================================== + // Auto-skill curator (/curator command) + // ========================================================================== + 'Maintain project auto-skills based on recent use.': + 'Gérer les compétences automatiques du projet selon leur utilisation récente.', + 'Show project auto-skill lifecycle status.': + 'Afficher l’état du cycle de vie des compétences automatiques du projet.', + 'Run project auto-skill lifecycle maintenance.': + 'Exécuter la maintenance du cycle de vie des compétences automatiques du projet.', + 'Restore an archived project auto-skill.': + 'Restaurer une compétence automatique du projet archivée.', + 'Auto-skill curator': 'Gestionnaire de compétences automatiques', + 'Last run: {{time}}': 'Dernière exécution : {{time}}', + 'Active: {{count}}': 'Actives : {{count}}', + 'Stale: {{count}}': 'Obsolètes : {{count}}', + 'Archived: {{count}}': 'Archivées : {{count}}', + 'Stale skills:': 'Compétences obsolètes :', + 'Pinned skills:': 'Compétences épinglées :', + 'Archived skills:': 'Compétences archivées :', + 'Dry run complete.': 'Simulation terminée.', + 'Curator run complete.': 'Exécution du gestionnaire terminée.', + 'Checked: {{count}}': 'Vérifiées : {{count}}', + 'First observed: {{count}}': 'Observées pour la première fois : {{count}}', + 'Marked stale: {{count}}': 'Marquées comme obsolètes : {{count}}', + 'Reactivated: {{count}}': 'Réactivées : {{count}}', + 'Skipped archive collisions: {{count}}': + "Collisions d'archivage ignorées : {{count}}", + 'Archive candidates:': "Candidates à l'archivage :", + 'Skipped archive collisions:': "Collisions d'archivage ignorées :", + 'Skipped rename errors: {{count}}': + 'Erreurs de renommage ignorées : {{count}}', + 'Skipped rename errors:': 'Erreurs de renommage ignorées :', + '{{verb}}: {{count}}': '{{verb}} : {{count}}', + 'Would archive': 'Seraient archivées', + Archived: 'Archivées', + 'Failed to read auto-skill curator status: {{message}}': + "Impossible de lire l'état du gestionnaire de compétences automatiques : {{message}}", + 'Usage: /curator run [--dry-run]': 'Utilisation : /curator run [--dry-run]', + 'Failed to run auto-skill curator: {{message}}': + "Impossible d'exécuter le gestionnaire de compétences automatiques : {{message}}", + 'Usage: /curator restore ': + 'Utilisation : /curator restore ', + 'Restored auto-skill: {{name}}': + 'Compétence automatique restaurée : {{name}}', + 'Failed to restore auto-skill: {{message}}': + 'Échec de la restauration de la compétence automatique : {{message}}', + 'Exclude an auto-skill from automatic maintenance.': + 'Exclure une compétence automatique de la maintenance automatique.', + 'Return a pinned auto-skill to automatic maintenance.': + 'Réintégrer une compétence automatique épinglée à la maintenance automatique.', + 'Usage: /curator pin ': 'Utilisation : /curator pin ', + 'Usage: /curator unpin ': + 'Utilisation : /curator unpin ', + 'Pinned auto-skill: {{name}}': 'Compétence automatique épinglée : {{name}}', + 'Unpinned auto-skill: {{name}}': + 'Compétence automatique désépinglée : {{name}}', + 'Failed to update auto-skill pin: {{message}}': + "Impossible de modifier l'épinglage de la compétence automatique : {{message}}", + 'Auto-skill curator changes are disabled in safe mode.': + 'Les modifications du gestionnaire de compétences automatiques sont désactivées en mode sécurisé.', + 'Auto-skill curator changes are only available in trusted workspaces. Trust this folder via `/trust` and try again.': + 'Les modifications du gestionnaire de compétences automatiques ne sont disponibles que dans les espaces de travail approuvés. Marquez ce dossier comme approuvé avec `/trust`, puis réessayez.', }; diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js index 0e4fe3b59a7..c2d8439b4e5 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -2005,4 +2005,64 @@ export default { '書き込みに失敗したため、セッションの記録を停止しました。影響を受けたセッションの新しいメッセージは保存されません。ディスク容量と権限を確認してから、新しいセッションを開始して記録を再開してください。詳細はデバッグログを確認してください。', 'Session recording stopped after a write failure. New messages for the affected session will not be saved. Check disk space and permissions, then run `/clear` to start a new recorded session. See the debug log for details.': '書き込みに失敗したため、セッションの記録を停止しました。影響を受けたセッションの新しいメッセージは保存されません。ディスク容量と権限を確認してから、`/clear` を実行して記録可能な新しいセッションを開始してください。詳細はデバッグログを確認してください。', + + // ========================================================================== + // Auto-skill curator (/curator command) + // ========================================================================== + 'Maintain project auto-skills based on recent use.': + '最近の使用状況に基づいてプロジェクトの自動スキルを管理します。', + 'Show project auto-skill lifecycle status.': + 'プロジェクトの自動スキルのライフサイクル状態を表示します。', + 'Run project auto-skill lifecycle maintenance.': + 'プロジェクトの自動スキルのライフサイクル保守を実行します。', + 'Restore an archived project auto-skill.': + 'アーカイブ済みのプロジェクト自動スキルを復元します。', + 'Auto-skill curator': '自動スキル管理', + 'Last run: {{time}}': '前回の実行:{{time}}', + 'Active: {{count}}': '有効:{{count}}', + 'Stale: {{count}}': '非アクティブ:{{count}}', + 'Archived: {{count}}': 'アーカイブ済み:{{count}}', + 'Stale skills:': '非アクティブなスキル:', + 'Pinned skills:': '固定済みのスキル:', + 'Archived skills:': 'アーカイブ済みのスキル:', + 'Dry run complete.': 'ドライランが完了しました。', + 'Curator run complete.': '自動スキル管理の実行が完了しました。', + 'Checked: {{count}}': '確認済み:{{count}}', + 'First observed: {{count}}': '初回検出:{{count}}', + 'Marked stale: {{count}}': '非アクティブ化:{{count}}', + 'Reactivated: {{count}}': '再有効化:{{count}}', + 'Skipped archive collisions: {{count}}': + 'スキップしたアーカイブ先の競合:{{count}}', + 'Archive candidates:': 'アーカイブ候補:', + 'Skipped archive collisions:': 'スキップしたアーカイブ先の競合:', + 'Skipped rename errors: {{count}}': 'スキップした名前変更エラー:{{count}}', + 'Skipped rename errors:': 'スキップした名前変更エラー:', + '{{verb}}: {{count}}': '{{verb}}:{{count}}', + 'Would archive': 'アーカイブ予定', + Archived: 'アーカイブ済み', + 'Failed to read auto-skill curator status: {{message}}': + '自動スキル管理の状態を読み取れませんでした:{{message}}', + 'Usage: /curator run [--dry-run]': '使用方法:/curator run [--dry-run]', + 'Failed to run auto-skill curator: {{message}}': + '自動スキル管理を実行できませんでした:{{message}}', + 'Usage: /curator restore ': + '使用方法:/curator restore <ディレクトリ>', + 'Restored auto-skill: {{name}}': '自動スキルを復元しました:{{name}}', + 'Failed to restore auto-skill: {{message}}': + '自動スキルを復元できませんでした:{{message}}', + 'Exclude an auto-skill from automatic maintenance.': + '自動スキルを自動保守の対象外にします。', + 'Return a pinned auto-skill to automatic maintenance.': + '固定済みの自動スキルを自動保守の対象に戻します。', + 'Usage: /curator pin ': '使用方法:/curator pin <ディレクトリ>', + 'Usage: /curator unpin ': + '使用方法:/curator unpin <ディレクトリ>', + 'Pinned auto-skill: {{name}}': '自動スキルを固定しました:{{name}}', + 'Unpinned auto-skill: {{name}}': '自動スキルの固定を解除しました:{{name}}', + 'Failed to update auto-skill pin: {{message}}': + '自動スキルの固定状態を更新できませんでした:{{message}}', + 'Auto-skill curator changes are disabled in safe mode.': + 'セーフモードでは自動スキル管理による変更は無効です。', + 'Auto-skill curator changes are only available in trusted workspaces. Trust this folder via `/trust` and try again.': + '自動スキル管理による変更は信頼済みのワークスペースでのみ利用できます。`/trust` でこのフォルダーを信頼してから、もう一度お試しください。', }; diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js index fabbce1749c..4d25ca0783c 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -2224,4 +2224,63 @@ export default { 'A gravação da sessão foi interrompida após uma falha de escrita. As novas mensagens da sessão afetada não serão salvas. Verifique o espaço em disco e as permissões e inicie uma nova sessão para retomar a gravação. Consulte o log de depuração para obter detalhes.', 'Session recording stopped after a write failure. New messages for the affected session will not be saved. Check disk space and permissions, then run `/clear` to start a new recorded session. See the debug log for details.': 'A gravação da sessão foi interrompida após uma falha de escrita. As novas mensagens da sessão afetada não serão salvas. Verifique o espaço em disco e as permissões e execute `/clear` para iniciar uma nova sessão gravada. Consulte o log de depuração para obter detalhes.', + + // ========================================================================== + // Auto-skill curator (/curator command) + // ========================================================================== + 'Maintain project auto-skills based on recent use.': + 'Gerenciar as habilidades automáticas do projeto com base no uso recente.', + 'Show project auto-skill lifecycle status.': + 'Mostrar o status do ciclo de vida das habilidades automáticas do projeto.', + 'Run project auto-skill lifecycle maintenance.': + 'Executar a manutenção do ciclo de vida das habilidades automáticas do projeto.', + 'Restore an archived project auto-skill.': + 'Restaurar uma habilidade automática arquivada do projeto.', + 'Auto-skill curator': 'Gerenciador de habilidades automáticas', + 'Last run: {{time}}': 'Última execução: {{time}}', + 'Active: {{count}}': 'Ativas: {{count}}', + 'Stale: {{count}}': 'Inativas: {{count}}', + 'Archived: {{count}}': 'Arquivadas: {{count}}', + 'Stale skills:': 'Habilidades inativas:', + 'Pinned skills:': 'Habilidades fixadas:', + 'Archived skills:': 'Habilidades arquivadas:', + 'Dry run complete.': 'Simulação concluída.', + 'Curator run complete.': 'Execução do gerenciador concluída.', + 'Checked: {{count}}': 'Verificadas: {{count}}', + 'First observed: {{count}}': 'Observadas pela primeira vez: {{count}}', + 'Marked stale: {{count}}': 'Marcadas como inativas: {{count}}', + 'Reactivated: {{count}}': 'Reativadas: {{count}}', + 'Skipped archive collisions: {{count}}': + 'Colisões de arquivamento ignoradas: {{count}}', + 'Archive candidates:': 'Candidatas ao arquivamento:', + 'Skipped archive collisions:': 'Colisões de arquivamento ignoradas:', + 'Skipped rename errors: {{count}}': + 'Erros de renomeação ignorados: {{count}}', + 'Skipped rename errors:': 'Erros de renomeação ignorados:', + '{{verb}}: {{count}}': '{{verb}}: {{count}}', + 'Would archive': 'Seriam arquivadas', + Archived: 'Arquivadas', + 'Failed to read auto-skill curator status: {{message}}': + 'Falha ao ler o status do gerenciador de habilidades automáticas: {{message}}', + 'Usage: /curator run [--dry-run]': 'Uso: /curator run [--dry-run]', + 'Failed to run auto-skill curator: {{message}}': + 'Falha ao executar o gerenciador de habilidades automáticas: {{message}}', + 'Usage: /curator restore ': 'Uso: /curator restore ', + 'Restored auto-skill: {{name}}': 'Habilidade automática restaurada: {{name}}', + 'Failed to restore auto-skill: {{message}}': + 'Falha ao restaurar a habilidade automática: {{message}}', + 'Exclude an auto-skill from automatic maintenance.': + 'Excluir uma habilidade automática da manutenção automática.', + 'Return a pinned auto-skill to automatic maintenance.': + 'Retornar uma habilidade automática fixada à manutenção automática.', + 'Usage: /curator pin ': 'Uso: /curator pin ', + 'Usage: /curator unpin ': 'Uso: /curator unpin ', + 'Pinned auto-skill: {{name}}': 'Habilidade automática fixada: {{name}}', + 'Unpinned auto-skill: {{name}}': 'Habilidade automática desafixada: {{name}}', + 'Failed to update auto-skill pin: {{message}}': + 'Falha ao atualizar a fixação da habilidade automática: {{message}}', + 'Auto-skill curator changes are disabled in safe mode.': + 'As alterações do gerenciador de habilidades automáticas estão desativadas no modo seguro.', + 'Auto-skill curator changes are only available in trusted workspaces. Trust this folder via `/trust` and try again.': + 'As alterações do gerenciador de habilidades automáticas estão disponíveis apenas em espaços de trabalho confiáveis. Marque esta pasta como confiável usando `/trust` e tente novamente.', }; diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index 69a83c02df3..4ffbe37705b 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -2212,4 +2212,66 @@ export default { 'Запись сеанса остановлена после ошибки записи. Новые сообщения затронутого сеанса не будут сохранены. Проверьте свободное место и разрешения, затем начните новый сеанс, чтобы возобновить запись. Подробности см. в журнале отладки.', 'Session recording stopped after a write failure. New messages for the affected session will not be saved. Check disk space and permissions, then run `/clear` to start a new recorded session. See the debug log for details.': 'Запись сеанса остановлена после ошибки записи. Новые сообщения затронутого сеанса не будут сохранены. Проверьте свободное место и разрешения, затем выполните `/clear`, чтобы начать новый записываемый сеанс. Подробности см. в журнале отладки.', + + // ========================================================================== + // Auto-skill curator (/curator command) + // ========================================================================== + 'Maintain project auto-skills based on recent use.': + 'Управление автоматическими навыками проекта с учетом недавнего использования.', + 'Show project auto-skill lifecycle status.': + 'Показать состояние жизненного цикла автоматических навыков проекта.', + 'Run project auto-skill lifecycle maintenance.': + 'Запустить обслуживание жизненного цикла автоматических навыков проекта.', + 'Restore an archived project auto-skill.': + 'Восстановить архивированный автоматический навык проекта.', + 'Auto-skill curator': 'Куратор автоматических навыков', + 'Last run: {{time}}': 'Последний запуск: {{time}}', + 'Active: {{count}}': 'Активные: {{count}}', + 'Stale: {{count}}': 'Устаревшие: {{count}}', + 'Archived: {{count}}': 'Архивированные: {{count}}', + 'Stale skills:': 'Устаревшие навыки:', + 'Pinned skills:': 'Закрепленные навыки:', + 'Archived skills:': 'Архивированные навыки:', + 'Dry run complete.': 'Пробный запуск завершен.', + 'Curator run complete.': 'Запуск куратора завершен.', + 'Checked: {{count}}': 'Проверено: {{count}}', + 'First observed: {{count}}': 'Обнаружено впервые: {{count}}', + 'Marked stale: {{count}}': 'Отмечено как устаревшие: {{count}}', + 'Reactivated: {{count}}': 'Повторно активировано: {{count}}', + 'Skipped archive collisions: {{count}}': + 'Пропущено конфликтов архивирования: {{count}}', + 'Archive candidates:': 'Кандидаты на архивирование:', + 'Skipped archive collisions:': 'Пропущенные конфликты архивирования:', + 'Skipped rename errors: {{count}}': + 'Пропущено ошибок переименования: {{count}}', + 'Skipped rename errors:': 'Пропущенные ошибки переименования:', + '{{verb}}: {{count}}': '{{verb}}: {{count}}', + 'Would archive': 'Будет архивировано', + Archived: 'Архивировано', + 'Failed to read auto-skill curator status: {{message}}': + 'Не удалось прочитать состояние куратора автоматических навыков: {{message}}', + 'Usage: /curator run [--dry-run]': 'Использование: /curator run [--dry-run]', + 'Failed to run auto-skill curator: {{message}}': + 'Не удалось запустить куратор автоматических навыков: {{message}}', + 'Usage: /curator restore ': + 'Использование: /curator restore <каталог>', + 'Restored auto-skill: {{name}}': + 'Автоматический навык восстановлен: {{name}}', + 'Failed to restore auto-skill: {{message}}': + 'Не удалось восстановить автоматический навык: {{message}}', + 'Exclude an auto-skill from automatic maintenance.': + 'Исключить автоматический навык из автоматического обслуживания.', + 'Return a pinned auto-skill to automatic maintenance.': + 'Вернуть закрепленный автоматический навык в автоматическое обслуживание.', + 'Usage: /curator pin ': 'Использование: /curator pin <каталог>', + 'Usage: /curator unpin ': + 'Использование: /curator unpin <каталог>', + 'Pinned auto-skill: {{name}}': 'Автоматический навык закреплен: {{name}}', + 'Unpinned auto-skill: {{name}}': 'Автоматический навык откреплен: {{name}}', + 'Failed to update auto-skill pin: {{message}}': + 'Не удалось изменить закрепление автоматического навыка: {{message}}', + 'Auto-skill curator changes are disabled in safe mode.': + 'Изменения куратора автоматических навыков отключены в безопасном режиме.', + 'Auto-skill curator changes are only available in trusted workspaces. Trust this folder via `/trust` and try again.': + 'Изменения куратора автоматических навыков доступны только в доверенных рабочих пространствах. Сделайте эту папку доверенной с помощью `/trust` и повторите попытку.', }; diff --git a/packages/cli/src/i18n/locales/zh-TW.js b/packages/cli/src/i18n/locales/zh-TW.js index f2bd5c22dcb..c08e0007870 100644 --- a/packages/cli/src/i18n/locales/zh-TW.js +++ b/packages/cli/src/i18n/locales/zh-TW.js @@ -2325,4 +2325,55 @@ export default { '工作階段錄製因寫入失敗而停止。受影響工作階段中的新訊息將不會被儲存。請檢查磁碟空間和權限,然後建立新的工作階段以恢復錄製。詳細資訊請查看偵錯日誌。', 'Session recording stopped after a write failure. New messages for the affected session will not be saved. Check disk space and permissions, then run `/clear` to start a new recorded session. See the debug log for details.': '工作階段錄製因寫入失敗而停止。受影響工作階段中的新訊息將不會被儲存。請檢查磁碟空間和權限,然後執行 `/clear` 建立新的可錄製工作階段。詳細資訊請查看偵錯日誌。', + 'Maintain project auto-skills based on recent use.': + '根據最近的使用情況維護專案自動技能。', + 'Show project auto-skill lifecycle status.': + '顯示專案自動技能的生命週期狀態。', + 'Run project auto-skill lifecycle maintenance.': + '執行專案自動技能的生命週期維護。', + 'Restore an archived project auto-skill.': '還原已封存的專案自動技能。', + 'Auto-skill curator': '自動技能管理器', + 'Last run: {{time}}': '上次執行:{{time}}', + 'Active: {{count}}': '使用中:{{count}}', + 'Stale: {{count}}': '陳舊:{{count}}', + 'Archived: {{count}}': '已封存:{{count}}', + 'Stale skills:': '陳舊技能:', + 'Pinned skills:': '固定技能:', + 'Archived skills:': '已封存技能:', + 'Dry run complete.': '試執行完成。', + 'Curator run complete.': '維護執行完成。', + 'Checked: {{count}}': '已檢查:{{count}}', + 'First observed: {{count}}': '首次發現:{{count}}', + 'Marked stale: {{count}}': '已標記為陳舊:{{count}}', + 'Reactivated: {{count}}': '已重新啟用:{{count}}', + 'Skipped archive collisions: {{count}}': '已略過封存衝突:{{count}}', + 'Archive candidates:': '待封存技能:', + 'Skipped archive collisions:': '已略過的封存衝突:', + 'Skipped rename errors: {{count}}': '已略過重新命名錯誤:{{count}}', + 'Skipped rename errors:': '已略過的重新命名錯誤:', + '{{verb}}: {{count}}': '{{verb}}:{{count}}', + 'Would archive': '將封存', + Archived: '已封存', + 'Failed to read auto-skill curator status: {{message}}': + '讀取自動技能管理器狀態失敗:{{message}}', + 'Usage: /curator run [--dry-run]': '用法:/curator run [--dry-run]', + 'Failed to run auto-skill curator: {{message}}': + '執行自動技能管理器失敗:{{message}}', + 'Usage: /curator restore ': '用法:/curator restore ', + 'Restored auto-skill: {{name}}': '已還原自動技能:{{name}}', + 'Failed to restore auto-skill: {{message}}': '還原自動技能失敗:{{message}}', + 'Exclude an auto-skill from automatic maintenance.': + '將自動技能排除於自動維護之外。', + 'Return a pinned auto-skill to automatic maintenance.': + '讓固定的自動技能重新接受自動維護。', + 'Usage: /curator pin ': '用法:/curator pin ', + 'Usage: /curator unpin ': '用法:/curator unpin ', + 'Pinned auto-skill: {{name}}': '已固定自動技能:{{name}}', + 'Unpinned auto-skill: {{name}}': '已取消固定自動技能:{{name}}', + 'Failed to update auto-skill pin: {{message}}': + '更新自動技能固定狀態失敗:{{message}}', + 'Auto-skill curator changes are disabled in safe mode.': + '安全模式下禁止變更自動技能管理器。', + 'Auto-skill curator changes are only available in trusted workspaces. Trust this folder via `/trust` and try again.': + '只有受信任的工作區可以變更自動技能管理器。請透過 `/trust` 信任此資料夾後再試一次。', }; diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 91138e25dc6..f553bb8c27a 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -2527,4 +2527,55 @@ export default { '会话录制因写入失败而停止。受影响会话中的新消息将不会被保存。请检查磁盘空间和权限,然后创建一个新会话以恢复录制。详情请查看调试日志。', 'Session recording stopped after a write failure. New messages for the affected session will not be saved. Check disk space and permissions, then run `/clear` to start a new recorded session. See the debug log for details.': '会话录制因写入失败而停止。受影响会话中的新消息将不会被保存。请检查磁盘空间和权限,然后运行 `/clear` 创建一个新的可录制会话。详情请查看调试日志。', + 'Maintain project auto-skills based on recent use.': + '根据最近的使用情况维护项目自动技能。', + 'Show project auto-skill lifecycle status.': + '显示项目自动技能的生命周期状态。', + 'Run project auto-skill lifecycle maintenance.': + '运行项目自动技能的生命周期维护。', + 'Restore an archived project auto-skill.': '恢复已归档的项目自动技能。', + 'Auto-skill curator': '自动技能管理器', + 'Last run: {{time}}': '上次运行:{{time}}', + 'Active: {{count}}': '活跃:{{count}}', + 'Stale: {{count}}': '陈旧:{{count}}', + 'Archived: {{count}}': '已归档:{{count}}', + 'Stale skills:': '陈旧技能:', + 'Pinned skills:': '固定技能:', + 'Archived skills:': '已归档技能:', + 'Dry run complete.': '试运行完成。', + 'Curator run complete.': '维护运行完成。', + 'Checked: {{count}}': '已检查:{{count}}', + 'First observed: {{count}}': '首次发现:{{count}}', + 'Marked stale: {{count}}': '已标记为陈旧:{{count}}', + 'Reactivated: {{count}}': '已重新激活:{{count}}', + 'Skipped archive collisions: {{count}}': '已跳过归档冲突:{{count}}', + 'Archive candidates:': '待归档技能:', + 'Skipped archive collisions:': '已跳过的归档冲突:', + 'Skipped rename errors: {{count}}': '已跳过重命名错误:{{count}}', + 'Skipped rename errors:': '已跳过的重命名错误:', + '{{verb}}: {{count}}': '{{verb}}:{{count}}', + 'Would archive': '将归档', + Archived: '已归档', + 'Failed to read auto-skill curator status: {{message}}': + '读取自动技能管理器状态失败:{{message}}', + 'Usage: /curator run [--dry-run]': '用法:/curator run [--dry-run]', + 'Failed to run auto-skill curator: {{message}}': + '运行自动技能管理器失败:{{message}}', + 'Usage: /curator restore ': '用法:/curator restore ', + 'Restored auto-skill: {{name}}': '已恢复自动技能:{{name}}', + 'Failed to restore auto-skill: {{message}}': '恢复自动技能失败:{{message}}', + 'Exclude an auto-skill from automatic maintenance.': + '将自动技能排除在自动维护之外。', + 'Return a pinned auto-skill to automatic maintenance.': + '恢复对固定自动技能的自动维护。', + 'Usage: /curator pin ': '用法:/curator pin ', + 'Usage: /curator unpin ': '用法:/curator unpin ', + 'Pinned auto-skill: {{name}}': '已固定自动技能:{{name}}', + 'Unpinned auto-skill: {{name}}': '已取消固定自动技能:{{name}}', + 'Failed to update auto-skill pin: {{message}}': + '更新自动技能固定状态失败:{{message}}', + 'Auto-skill curator changes are disabled in safe mode.': + '安全模式下禁止更改自动技能管理器。', + 'Auto-skill curator changes are only available in trusted workspaces. Trust this folder via `/trust` and try again.': + '仅受信任的工作区可以更改自动技能管理器。请通过 `/trust` 信任此文件夹后重试。', }; diff --git a/packages/cli/src/nonInteractiveCliCommands.test.ts b/packages/cli/src/nonInteractiveCliCommands.test.ts index 6aad1cb69e3..f2394b288e0 100644 --- a/packages/cli/src/nonInteractiveCliCommands.test.ts +++ b/packages/cli/src/nonInteractiveCliCommands.test.ts @@ -19,6 +19,12 @@ import { CommandKind, type ExecutionMode } from './ui/commands/types.js'; import { filterCommandsForMode } from './services/commandUtils.js'; import { goalCommand } from './ui/commands/goalCommand.js'; +const recordAutoSkillUsageMock = vi.hoisted(() => vi.fn()); +vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => ({ + ...(await importOriginal()), + recordAutoSkillUsage: recordAutoSkillUsageMock, +})); + // Mock the CommandService const mockGetCommands = vi.hoisted(() => vi.fn()); const mockGetCommandsForMode = vi.hoisted(() => vi.fn()); @@ -433,7 +439,11 @@ describe('handleSlashCommand', () => { name: 'review', description: 'Review code', kind: CommandKind.SKILL, - skillDetail: { name: 'review-skill' }, + skillDetail: { + name: 'review-skill', + level: 'project', + filePath: '/test/project/.qwen/skills/auto-skill-review/SKILL.md', + }, action: vi.fn().mockResolvedValue({ type: 'submit_prompt', content: [{ text: 'Review prompt' }], @@ -459,6 +469,11 @@ describe('handleSlashCommand', () => { 'review-skill': { count: 1, success: 1, fail: 0 }, }, }); + expect(recordAutoSkillUsageMock).toHaveBeenCalledWith('/test/project', { + name: 'review-skill', + level: 'project', + filePath: '/test/project/.qwen/skills/auto-skill-review/SKILL.md', + }); }); it('records ACP SKILL submit_prompt commands in session metrics', async () => { @@ -532,6 +547,11 @@ describe('handleSlashCommand', () => { name: 'review', description: 'Review code', kind: CommandKind.SKILL, + skillDetail: { + name: 'review', + level: 'project', + filePath: '/test/project/.qwen/skills/auto-skill-review/SKILL.md', + }, action: vi.fn().mockResolvedValue({ type: 'submit_prompt', content: 'Review prompt', @@ -557,6 +577,7 @@ describe('handleSlashCommand', () => { review: { count: 1, success: 0, fail: 1 }, }, }); + expect(recordAutoSkillUsageMock).not.toHaveBeenCalled(); }); it('records SKILL submit_prompt commands as failures when hooks throw', async () => { @@ -1061,6 +1082,83 @@ describe('handleSlashCommand', () => { expect(skillB.action).toHaveBeenCalledTimes(1); }); + it('records successful stacked project auto-skills as used', async () => { + const skillA = { + ...createSkillCommand('feat-dev', 'a'), + skillDetail: { + name: 'feat-dev', + level: 'project', + filePath: '/test/project/.qwen/skills/auto-skill-feat-dev/SKILL.md', + }, + }; + const skillB = { + ...createSkillCommand('review', 'b'), + skillDetail: { + name: 'review', + level: 'project', + filePath: '/test/project/.qwen/skills/auto-skill-review/SKILL.md', + }, + }; + mockGetCommands.mockReturnValue([skillA, skillB]); + + const result = await handleSlashCommand( + '/feat-dev /review do stuff', + abortController, + mockConfig, + mockSettings, + ); + + expect(result.type).toBe('submit_prompt'); + expect(recordAutoSkillUsageMock).toHaveBeenCalledTimes(2); + expect(recordAutoSkillUsageMock).toHaveBeenCalledWith('/test/project', { + name: 'feat-dev', + level: 'project', + filePath: '/test/project/.qwen/skills/auto-skill-feat-dev/SKILL.md', + }); + expect(recordAutoSkillUsageMock).toHaveBeenCalledWith('/test/project', { + name: 'review', + level: 'project', + filePath: '/test/project/.qwen/skills/auto-skill-review/SKILL.md', + }); + }); + + it('does not record blocked stacked auto-skills as used', async () => { + mockFireUserPromptExpansionEvent.mockResolvedValue({ + getBlockingError: () => ({ + blocked: true, + reason: 'Blocked by policy', + }), + shouldStopExecution: () => false, + }); + const skillA = { + ...createSkillCommand('feat-dev', 'a'), + skillDetail: { + name: 'feat-dev', + level: 'project', + filePath: '/test/project/.qwen/skills/auto-skill-feat-dev/SKILL.md', + }, + }; + const skillB = { + ...createSkillCommand('review', 'b'), + skillDetail: { + name: 'review', + level: 'project', + filePath: '/test/project/.qwen/skills/auto-skill-review/SKILL.md', + }, + }; + mockGetCommands.mockReturnValue([skillA, skillB]); + + const result = await handleSlashCommand( + '/feat-dev /review do stuff', + abortController, + mockConfig, + mockSettings, + ); + + expect(result.type).toBe('message'); + expect(recordAutoSkillUsageMock).not.toHaveBeenCalled(); + }); + it('handles stacked skills with no remaining text', async () => { const skillA = createSkillCommand('feat-dev', 'a'); const skillB = createSkillCommand('bugfix', 'b'); diff --git a/packages/cli/src/nonInteractiveCliCommands.ts b/packages/cli/src/nonInteractiveCliCommands.ts index 292bb802bda..6bcd6691886 100644 --- a/packages/cli/src/nonInteractiveCliCommands.ts +++ b/packages/cli/src/nonInteractiveCliCommands.ts @@ -22,7 +22,10 @@ import { BundledSkillLoader } from './services/BundledSkillLoader.js'; import { FileCommandLoader } from './services/FileCommandLoader.js'; import { SavedWorkflowLoader } from './services/saved-workflow-loader.js'; import { McpPromptLoader } from './services/McpPromptLoader.js'; -import { SkillCommandLoader } from './services/SkillCommandLoader.js'; +import { + recordAutoSkillCommandUsage, + SkillCommandLoader, +} from './services/SkillCommandLoader.js'; import { type CommandContext, CommandKind, @@ -406,6 +409,7 @@ export const handleSlashCommand = async ( const combinedContent: PartListUnion[] = []; let firstModelOverride: string | undefined; const onCompleteCallbacks: Array<() => Promise> = []; + const successfulSkillCommands: SlashCommand[] = []; for (const skill of stackedResult.skills) { if (!skill.action) continue; @@ -428,10 +432,14 @@ export const handleSlashCommand = async ( } } + const succeeded = skillResult?.type === 'submit_prompt'; recordSkillInvocation(config, { skillName: getSkillCommandName(skill), - success: skillResult?.type === 'submit_prompt', + success: succeeded, }); + if (succeeded) { + successfulSkillCommands.push(skill); + } } if (stackedResult.remainingText) { @@ -450,6 +458,9 @@ export const handleSlashCommand = async ( if (hookResult.blockedResult) { return hookResult.blockedResult; } + for (const skill of successfulSkillCommands) { + void recordAutoSkillCommandUsage(config, skill); + } return { type: 'submit_prompt', @@ -593,6 +604,7 @@ export const handleSlashCommand = async ( return hookResult.blockedResult; } recordSkillCommandInvocation(true); + void recordAutoSkillCommandUsage(config, commandToExecute); return handleCommandResult( { ...result, content: hookResult.content }, outputHistoryItems, diff --git a/packages/cli/src/services/BuiltinCommandLoader.test.ts b/packages/cli/src/services/BuiltinCommandLoader.test.ts index 76330872ef6..e284218b92d 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.test.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.test.ts @@ -201,6 +201,9 @@ describe('BuiltinCommandLoader', () => { const modelCmd = commands.find((c) => c.name === 'model'); expect(modelCmd).toBeDefined(); + + const curatorCmd = commands.find((c) => c.name === 'curator'); + expect(curatorCmd).toBeDefined(); }); it('should include trust command when folder trust is enabled', async () => { diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index 2e5794306f2..79c6ee2bf4b 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -24,6 +24,7 @@ import { deleteCommand } from '../ui/commands/deleteCommand.js'; import { compressCommand } from '../ui/commands/compressCommand.js'; import { compressFastCommand } from '../ui/commands/compressFastCommand.js'; import { contextCommand } from '../ui/commands/contextCommand.js'; +import { curatorCommand } from '../ui/commands/curator-command.js'; import { copyCommand } from '../ui/commands/copyCommand.js'; import { docsCommand } from '../ui/commands/docsCommand.js'; import { doctorCommand } from '../ui/commands/doctorCommand.js'; @@ -128,6 +129,7 @@ export class BuiltinCommandLoader implements ICommandLoader { compressFastCommand, configCommand, contextCommand, + curatorCommand, copyCommand, diffCommand, deleteCommand, diff --git a/packages/cli/src/services/SkillCommandLoader.test.ts b/packages/cli/src/services/SkillCommandLoader.test.ts index adff8d2032a..d9c200ccfa1 100644 --- a/packages/cli/src/services/SkillCommandLoader.test.ts +++ b/packages/cli/src/services/SkillCommandLoader.test.ts @@ -5,7 +5,10 @@ */ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { SkillCommandLoader } from './SkillCommandLoader.js'; +import { + recordAutoSkillCommandUsage, + SkillCommandLoader, +} from './SkillCommandLoader.js'; import { skillArgsPath } from './skill-args-file.js'; import { mkdtempSync, rmSync, readFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; @@ -17,6 +20,12 @@ import { type SkillConfig, } from '@qwen-code/qwen-code-core'; +const recordAutoSkillUsageMock = vi.hoisted(() => vi.fn()); +vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => ({ + ...(await importOriginal()), + recordAutoSkillUsage: recordAutoSkillUsageMock, +})); + function makeSkill(overrides: Partial = {}): SkillConfig { return { name: 'my-skill', @@ -46,6 +55,8 @@ describe('SkillCommandLoader', () => { mockConfig = { getSkillManager: vi.fn().mockReturnValue(mockSkillManager), getBareMode: vi.fn().mockReturnValue(false), + getProjectRoot: vi.fn().mockReturnValue('/test/project'), + getAutoSkillEnabled: vi.fn().mockReturnValue(true), getPermissionManager: vi .fn() .mockReturnValue({ addSessionAllowRule: mockAddSessionAllowRule }), @@ -181,6 +192,79 @@ describe('SkillCommandLoader', () => { expect(commands[0].sourceDetail).toBe('project'); expect(commands[0].source).toBe('skill-dir-command'); expect(commands[0].modelInvocable).toBe(true); + expect(commands[0].skillDetail?.filePath).toBe(skill.filePath); + + await recordAutoSkillCommandUsage(mockConfig, commands[0]); + expect(recordAutoSkillUsageMock).toHaveBeenCalledWith('/test/project', { + name: 'my-skill', + level: 'project', + filePath: skill.filePath, + }); + }); + + it('records curator usage while Auto Skill generation is disabled', async () => { + vi.mocked(mockConfig.getAutoSkillEnabled).mockReturnValue(false); + + await recordAutoSkillCommandUsage(mockConfig, { + name: 'my-skill', + description: 'My skill', + kind: CommandKind.SKILL, + skillDetail: { + name: 'my-skill', + level: 'project', + filePath: '/test/project/.qwen/skills/auto-skill-test/SKILL.md', + }, + }); + + expect(recordAutoSkillUsageMock).toHaveBeenCalledWith('/test/project', { + name: 'my-skill', + level: 'project', + filePath: '/test/project/.qwen/skills/auto-skill-test/SKILL.md', + }); + }); + + it.each([ + { + caseName: 'user-level skills', + skillDetail: { + name: 'my-skill', + level: 'user', + filePath: '/test/user/.qwen/skills/my-skill/SKILL.md', + }, + }, + { + caseName: 'skills without a file path', + skillDetail: { + name: 'my-skill', + level: 'project', + }, + }, + ])('does not record curator usage for $caseName', async ({ skillDetail }) => { + await recordAutoSkillCommandUsage(mockConfig, { + name: 'my-skill', + description: 'My skill', + kind: CommandKind.SKILL, + skillDetail, + }); + + expect(recordAutoSkillUsageMock).not.toHaveBeenCalled(); + }); + + it('keeps usage recording best-effort when persistence fails', async () => { + recordAutoSkillUsageMock.mockRejectedValueOnce(new Error('lock busy')); + + await expect( + recordAutoSkillCommandUsage(mockConfig, { + name: 'my-skill', + description: 'My skill', + kind: CommandKind.SKILL, + skillDetail: { + name: 'my-skill', + level: 'project', + filePath: '/test/project/.qwen/skills/auto-skill-test/SKILL.md', + }, + }), + ).resolves.toBeUndefined(); }); it('should submit skill body as prompt', async () => { diff --git a/packages/cli/src/services/SkillCommandLoader.ts b/packages/cli/src/services/SkillCommandLoader.ts index 8f3191c991c..fd0b594b08b 100644 --- a/packages/cli/src/services/SkillCommandLoader.ts +++ b/packages/cli/src/services/SkillCommandLoader.ts @@ -10,6 +10,7 @@ import { appendToLastTextPart, buildSkillLlmContent, applySkillAllowedTools, + recordAutoSkillUsage, } from '@qwen-code/qwen-code-core'; import { dirname } from 'node:path'; import type { ICommandLoader } from './types.js'; @@ -30,6 +31,27 @@ import { t } from '../i18n/index.js'; const debugLogger = createDebugLogger('SKILL_COMMAND_LOADER'); +export async function recordAutoSkillCommandUsage( + config: Config | null, + command: SlashCommand, +): Promise { + const detail = command.skillDetail; + if (!config || detail?.level !== 'project' || !detail.filePath) { + return; + } + try { + await recordAutoSkillUsage(config.getProjectRoot(), { + name: detail.name, + level: 'project', + filePath: detail.filePath, + }); + } catch (error) { + debugLogger.warn( + `Failed to record auto-skill command usage: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + /** * Loads user-level, project-level, and extension-level skills as slash * commands, making them directly invocable via /. @@ -122,6 +144,7 @@ export class SkillCommandLoader implements ICommandLoader { name: skill.name, description: skill.description, body: skill.body, + filePath: skill.filePath, level: skill.level, ...(isExtension && skill.extensionName ? { extensionName: skill.extensionName } diff --git a/packages/cli/src/ui/commands/curator-command.test.ts b/packages/cli/src/ui/commands/curator-command.test.ts new file mode 100644 index 00000000000..c4cfd77f35e --- /dev/null +++ b/packages/cli/src/ui/commands/curator-command.test.ts @@ -0,0 +1,342 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { CommandContext } from './types.js'; + +const mocks = vi.hoisted(() => ({ + getStatus: vi.fn(), + run: vi.fn(), + restore: vi.fn(), + setPinned: vi.fn(), + refreshCache: vi.fn(), + isSafeMode: vi.fn(), + isTrustedFolder: vi.fn(), +})); + +vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => ({ + ...(await importOriginal()), + getAutoSkillCuratorStatus: mocks.getStatus, + runAutoSkillCurator: mocks.run, + restoreArchivedAutoSkill: mocks.restore, + setAutoSkillPinned: mocks.setPinned, +})); + +import { curatorCommand } from './curator-command.js'; + +describe('curator command', () => { + let context: CommandContext; + + beforeEach(() => { + vi.resetAllMocks(); + mocks.isSafeMode.mockReturnValue(false); + mocks.isTrustedFolder.mockReturnValue(true); + context = { + services: { + config: { + getProjectRoot: () => '/project', + getSkillManager: () => ({ refreshCache: mocks.refreshCache }), + isSafeMode: mocks.isSafeMode, + isTrustedFolder: mocks.isTrustedFolder, + }, + }, + } as unknown as CommandContext; + mocks.getStatus.mockResolvedValue({ + lastRunAt: undefined, + active: [], + stale: [ + { + directoryName: 'auto-skill-old', + skillName: 'old', + state: 'stale', + lastActivityAt: '2026-01-01T00:00:00.000Z', + useCount: 0, + pinned: false, + }, + ], + archived: [], + }); + }); + + it('shows status from the bare parent command', async () => { + const result = await curatorCommand.action!(context, ''); + + expect(mocks.getStatus).toHaveBeenCalledWith('/project'); + expect(result).toMatchObject({ + type: 'message', + messageType: 'info', + }); + expect((result as { content: string }).content).toContain('auto-skill-old'); + }); + + it('runs a non-mutating preview', async () => { + mocks.run.mockResolvedValue({ + dryRun: true, + checked: 1, + seeded: [], + markedStale: [], + reactivated: [], + archived: ['auto-skill-old'], + skippedCollisions: ['auto-skill-collision'], + skippedErrors: [], + }); + const runCommand = curatorCommand.subCommands!.find( + (command) => command.name === 'run', + )!; + + const result = await runCommand.action!(context, '--dry-run'); + + expect(mocks.run).toHaveBeenCalledWith('/project', { dryRun: true }); + expect(mocks.refreshCache).not.toHaveBeenCalled(); + expect((result as { content: string }).content).toContain( + 'Archive candidates:\n auto-skill-old', + ); + expect((result as { content: string }).content).toContain( + 'Skipped archive collisions:\n auto-skill-collision', + ); + }); + + it('refreshes skill discovery after a live archive', async () => { + mocks.run.mockResolvedValue({ + dryRun: false, + checked: 1, + seeded: [], + markedStale: [], + reactivated: [], + archived: ['auto-skill-old'], + skippedCollisions: [], + skippedErrors: [], + }); + const runCommand = curatorCommand.subCommands!.find( + (command) => command.name === 'run', + )!; + + await runCommand.action!(context, ''); + + expect(mocks.refreshCache).toHaveBeenCalledTimes(1); + }); + + it('does not refresh skill discovery when a live run archives nothing', async () => { + mocks.run.mockResolvedValue({ + dryRun: false, + checked: 1, + seeded: [], + markedStale: [], + reactivated: [], + archived: [], + skippedCollisions: [], + skippedErrors: [], + }); + const runCommand = curatorCommand.subCommands!.find( + (command) => command.name === 'run', + )!; + + const result = await runCommand.action!(context, ''); + + expect(result).toMatchObject({ type: 'message', messageType: 'info' }); + expect(mocks.refreshCache).not.toHaveBeenCalled(); + }); + + it('keeps a successful live run successful when cache refresh fails', async () => { + mocks.run.mockResolvedValue({ + dryRun: false, + checked: 1, + seeded: [], + markedStale: [], + reactivated: [], + archived: ['auto-skill-old'], + skippedCollisions: [], + skippedErrors: [], + }); + mocks.refreshCache.mockRejectedValue(new Error('refresh exploded')); + const runCommand = curatorCommand.subCommands!.find( + (command) => command.name === 'run', + )!; + + const result = await runCommand.action!(context, ''); + + expect(result).toMatchObject({ type: 'message', messageType: 'info' }); + expect((result as { content: string }).content).toContain( + 'Archived skills:\n auto-skill-old', + ); + }); + + it('restores an archived directory and refreshes skill discovery', async () => { + const restoreCommand = curatorCommand.subCommands!.find( + (command) => command.name === 'restore', + )!; + + const result = await restoreCommand.action!(context, 'auto-skill-old'); + + expect(mocks.restore).toHaveBeenCalledWith('/project', 'auto-skill-old'); + expect(mocks.refreshCache).toHaveBeenCalledTimes(1); + expect(result).toMatchObject({ type: 'message', messageType: 'info' }); + }); + + it('keeps a successful restore successful when cache refresh fails', async () => { + mocks.refreshCache.mockRejectedValue(new Error('refresh exploded')); + const restoreCommand = curatorCommand.subCommands!.find( + (command) => command.name === 'restore', + )!; + + const result = await restoreCommand.action!(context, 'auto-skill-old'); + + expect(mocks.restore).toHaveBeenCalledWith('/project', 'auto-skill-old'); + expect(result).toMatchObject({ type: 'message', messageType: 'info' }); + expect((result as { content: string }).content).toContain( + 'Restored auto-skill: auto-skill-old', + ); + }); + + it('pins and unpins a managed auto-skill', async () => { + const pinCommand = curatorCommand.subCommands!.find( + (command) => command.name === 'pin', + )!; + const unpinCommand = curatorCommand.subCommands!.find( + (command) => command.name === 'unpin', + )!; + + await pinCommand.action!(context, 'auto-skill-old'); + await unpinCommand.action!(context, 'auto-skill-old'); + + expect(mocks.setPinned).toHaveBeenNthCalledWith( + 1, + '/project', + 'auto-skill-old', + true, + ); + expect(mocks.setPinned).toHaveBeenNthCalledWith( + 2, + '/project', + 'auto-skill-old', + false, + ); + }); + + it('reports an error when reading status fails', async () => { + mocks.getStatus.mockRejectedValue(new Error('state unreadable')); + + const result = await curatorCommand.action!(context, ''); + + expect(result).toMatchObject({ type: 'message', messageType: 'error' }); + expect((result as { content: string }).content).toContain( + 'state unreadable', + ); + }); + + it('reports an error and skips refresh when a live run fails', async () => { + mocks.run.mockRejectedValue(new Error('run exploded')); + const runCommand = curatorCommand.subCommands!.find( + (command) => command.name === 'run', + )!; + + const result = await runCommand.action!(context, ''); + + expect(result).toMatchObject({ type: 'message', messageType: 'error' }); + expect((result as { content: string }).content).toContain('run exploded'); + expect(mocks.refreshCache).not.toHaveBeenCalled(); + }); + + it('reports an error and skips refresh when restore fails', async () => { + mocks.restore.mockRejectedValue(new Error('restore exploded')); + const restoreCommand = curatorCommand.subCommands!.find( + (command) => command.name === 'restore', + )!; + + const result = await restoreCommand.action!(context, 'auto-skill-old'); + + expect(result).toMatchObject({ type: 'message', messageType: 'error' }); + expect((result as { content: string }).content).toContain( + 'restore exploded', + ); + expect(mocks.refreshCache).not.toHaveBeenCalled(); + }); + + it('reports an error when pinning fails', async () => { + mocks.setPinned.mockRejectedValue(new Error('pin exploded')); + const pinCommand = curatorCommand.subCommands!.find( + (command) => command.name === 'pin', + )!; + + const result = await pinCommand.action!(context, 'auto-skill-old'); + + expect(result).toMatchObject({ type: 'message', messageType: 'error' }); + expect((result as { content: string }).content).toContain('pin exploded'); + }); + + it('rejects unsupported run arguments', async () => { + const runCommand = curatorCommand.subCommands!.find( + (command) => command.name === 'run', + )!; + + const result = await runCommand.action!(context, '--days 1'); + + expect(mocks.run).not.toHaveBeenCalled(); + expect(result).toMatchObject({ type: 'message', messageType: 'error' }); + }); + + it.each([ + ['safe mode', true, true], + ['an untrusted workspace', false, false], + ])( + 'blocks mutations but preserves read-only commands in %s', + async (_name, safeMode, trustedFolder) => { + mocks.isSafeMode.mockReturnValue(safeMode); + mocks.isTrustedFolder.mockReturnValue(trustedFolder); + mocks.run.mockResolvedValue({ + dryRun: true, + checked: 0, + seeded: [], + markedStale: [], + reactivated: [], + archived: [], + skippedCollisions: [], + skippedErrors: [], + }); + const runCommand = curatorCommand.subCommands!.find( + (command) => command.name === 'run', + )!; + const pinCommand = curatorCommand.subCommands!.find( + (command) => command.name === 'pin', + )!; + const unpinCommand = curatorCommand.subCommands!.find( + (command) => command.name === 'unpin', + )!; + const restoreCommand = curatorCommand.subCommands!.find( + (command) => command.name === 'restore', + )!; + + const statusResult = await curatorCommand.action!(context, ''); + const previewResult = await runCommand.action!(context, '--dry-run'); + const blockedResults = await Promise.all([ + runCommand.action!(context, ''), + pinCommand.action!(context, 'auto-skill-old'), + unpinCommand.action!(context, 'auto-skill-old'), + restoreCommand.action!(context, 'auto-skill-old'), + ]); + + expect(statusResult).toMatchObject({ + type: 'message', + messageType: 'info', + }); + expect(previewResult).toMatchObject({ + type: 'message', + messageType: 'info', + }); + expect(mocks.run).toHaveBeenCalledTimes(1); + expect(mocks.run).toHaveBeenCalledWith('/project', { dryRun: true }); + expect(mocks.setPinned).not.toHaveBeenCalled(); + expect(mocks.restore).not.toHaveBeenCalled(); + expect(mocks.refreshCache).not.toHaveBeenCalled(); + for (const result of blockedResults) { + expect(result).toMatchObject({ + type: 'message', + messageType: 'error', + }); + } + }, + ); +}); diff --git a/packages/cli/src/ui/commands/curator-command.ts b/packages/cli/src/ui/commands/curator-command.ts new file mode 100644 index 00000000000..bf06b37e289 --- /dev/null +++ b/packages/cli/src/ui/commands/curator-command.ts @@ -0,0 +1,333 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + getAutoSkillCuratorStatus, + restoreArchivedAutoSkill, + runAutoSkillCurator, + setAutoSkillPinned, + type AutoSkillCuratorEntry, + type AutoSkillCuratorRunResult, + type AutoSkillCuratorStatus, + type Config, +} from '@qwen-code/qwen-code-core'; +import { t } from '../../i18n/index.js'; +import type { + CommandContext, + MessageActionReturn, + SlashCommand, +} from './types.js'; +import { CommandKind } from './types.js'; + +function message( + content: string, + messageType: MessageActionReturn['messageType'] = 'info', +): MessageActionReturn { + return { type: 'message', messageType, content }; +} + +function mutationGuard(config: Config): MessageActionReturn | undefined { + if (config.isSafeMode()) { + return message( + t('Auto-skill curator changes are disabled in safe mode.'), + 'error', + ); + } + if (!config.isTrustedFolder()) { + return message( + t( + 'Auto-skill curator changes are only available in trusted workspaces. Trust this folder via `/trust` and try again.', + ), + 'error', + ); + } + return undefined; +} + +async function refreshSkillCache(config: Config): Promise { + try { + await config.getSkillManager()?.refreshCache(); + } catch { + // Cache refresh is best-effort. The primary mutation already succeeded, + // so do not turn a transient refresh failure into a misleading retry. + } +} + +function displayName(entry: AutoSkillCuratorEntry): string { + return entry.skillName === entry.directoryName + ? entry.directoryName + : `${entry.skillName} (${entry.directoryName})`; +} + +function formatStatus(status: AutoSkillCuratorStatus): string { + const lines = [ + t('Auto-skill curator'), + t('Last run: {{time}}', { time: status.lastRunAt ?? t('never') }), + t('Active: {{count}}', { count: String(status.active.length) }), + t('Stale: {{count}}', { count: String(status.stale.length) }), + t('Archived: {{count}}', { count: String(status.archived.length) }), + ]; + if (status.stale.length > 0) { + lines.push('', t('Stale skills:')); + lines.push(...status.stale.map((entry) => ` ${displayName(entry)}`)); + } + const pinned = [...status.active, ...status.stale].filter( + (entry) => entry.pinned, + ); + if (pinned.length > 0) { + lines.push('', t('Pinned skills:')); + lines.push(...pinned.map((entry) => ` ${displayName(entry)}`)); + } + if (status.archived.length > 0) { + lines.push('', t('Archived skills:')); + lines.push(...status.archived.map((entry) => ` ${displayName(entry)}`)); + } + return lines.join('\n'); +} + +function formatRun(result: AutoSkillCuratorRunResult): string { + const prefix = result.dryRun + ? t('Dry run complete.') + : t('Curator run complete.'); + const lines = [ + prefix, + t('Checked: {{count}}', { count: String(result.checked) }), + t('First observed: {{count}}', { count: String(result.seeded.length) }), + t('Marked stale: {{count}}', { + count: String(result.markedStale.length), + }), + t('Reactivated: {{count}}', { + count: String(result.reactivated.length), + }), + t('{{verb}}: {{count}}', { + verb: result.dryRun ? t('Would archive') : t('Archived'), + count: String(result.archived.length), + }), + t('Skipped archive collisions: {{count}}', { + count: String(result.skippedCollisions.length), + }), + t('Skipped rename errors: {{count}}', { + count: String(result.skippedErrors.length), + }), + ]; + if (result.archived.length > 0) { + lines.push( + '', + result.dryRun ? t('Archive candidates:') : t('Archived skills:'), + ); + lines.push(...result.archived.map((name) => ` ${name}`)); + } + if (result.skippedCollisions.length > 0) { + lines.push('', t('Skipped archive collisions:')); + lines.push( + ...result.skippedCollisions.map( + (name) => + ` ${name} — remove or rename .qwen/archived-skills/${name} to re-archive`, + ), + ); + } + if (result.skippedErrors.length > 0) { + lines.push('', t('Skipped rename errors:')); + lines.push(...result.skippedErrors.map((name) => ` ${name}`)); + } + return lines.join('\n'); +} + +async function statusAction( + context: CommandContext, +): Promise { + const config = context.services.config; + if (!config) return message(t('Config not loaded.'), 'error'); + try { + return message( + formatStatus(await getAutoSkillCuratorStatus(config.getProjectRoot())), + ); + } catch (error) { + return message( + t('Failed to read auto-skill curator status: {{message}}', { + message: error instanceof Error ? error.message : String(error), + }), + 'error', + ); + } +} + +const statusCommand: SlashCommand = { + name: 'status', + get description() { + return t('Show project auto-skill lifecycle status.'); + }, + kind: CommandKind.BUILT_IN, + supportedModes: ['interactive', 'non_interactive', 'acp'] as const, + action: statusAction, +}; + +const runCommand: SlashCommand = { + name: 'run', + get description() { + return t('Run project auto-skill lifecycle maintenance.'); + }, + argumentHint: '[--dry-run]', + kind: CommandKind.BUILT_IN, + supportedModes: ['interactive', 'non_interactive', 'acp'] as const, + action: async (context, args) => { + const config = context.services.config; + if (!config) return message(t('Config not loaded.'), 'error'); + const normalized = args.trim(); + if (normalized !== '' && normalized !== '--dry-run') { + return message(t('Usage: /curator run [--dry-run]'), 'error'); + } + if (normalized !== '--dry-run') { + const blocked = mutationGuard(config); + if (blocked) return blocked; + } + try { + const result = await runAutoSkillCurator(config.getProjectRoot(), { + dryRun: normalized === '--dry-run', + }); + if (!result.dryRun && result.archived.length > 0) { + await refreshSkillCache(config); + } + return message(formatRun(result)); + } catch (error) { + return message( + t('Failed to run auto-skill curator: {{message}}', { + message: error instanceof Error ? error.message : String(error), + }), + 'error', + ); + } + }, +}; + +const restoreCommand: SlashCommand = { + name: 'restore', + get description() { + return t('Restore an archived project auto-skill.'); + }, + argumentHint: '', + kind: CommandKind.BUILT_IN, + supportedModes: ['interactive', 'non_interactive', 'acp'] as const, + action: async (context, args) => { + const config = context.services.config; + if (!config) return message(t('Config not loaded.'), 'error'); + const directoryName = args.trim(); + if (!directoryName) { + return message(t('Usage: /curator restore '), 'error'); + } + const blocked = mutationGuard(config); + if (blocked) return blocked; + try { + await restoreArchivedAutoSkill(config.getProjectRoot(), directoryName); + await refreshSkillCache(config); + return message( + t('Restored auto-skill: {{name}}', { name: directoryName }), + ); + } catch (error) { + return message( + t('Failed to restore auto-skill: {{message}}', { + message: error instanceof Error ? error.message : String(error), + }), + 'error', + ); + } + }, + completion: async (context, partialArg) => { + const config = context.services.config; + if (!config) return []; + try { + const status = await getAutoSkillCuratorStatus(config.getProjectRoot()); + return status.archived + .map((entry) => entry.directoryName) + .filter((name) => name.startsWith(partialArg)); + } catch { + return []; + } + }, +}; + +function pinCommand(name: 'pin' | 'unpin', pinned: boolean): SlashCommand { + return { + name, + get description() { + return pinned + ? t('Exclude an auto-skill from automatic maintenance.') + : t('Return a pinned auto-skill to automatic maintenance.'); + }, + argumentHint: '', + kind: CommandKind.BUILT_IN, + supportedModes: ['interactive', 'non_interactive', 'acp'] as const, + action: async (context, args) => { + const config = context.services.config; + if (!config) return message(t('Config not loaded.'), 'error'); + const directoryName = args.trim(); + if (!directoryName) { + return message( + pinned + ? t('Usage: /curator pin ') + : t('Usage: /curator unpin '), + 'error', + ); + } + const blocked = mutationGuard(config); + if (blocked) return blocked; + try { + await setAutoSkillPinned( + config.getProjectRoot(), + directoryName, + pinned, + ); + return message( + pinned + ? t('Pinned auto-skill: {{name}}', { name: directoryName }) + : t('Unpinned auto-skill: {{name}}', { name: directoryName }), + ); + } catch (error) { + return message( + t('Failed to update auto-skill pin: {{message}}', { + message: error instanceof Error ? error.message : String(error), + }), + 'error', + ); + } + }, + completion: async (context, partialArg) => { + const config = context.services.config; + if (!config) return []; + try { + const status = await getAutoSkillCuratorStatus(config.getProjectRoot()); + return [...status.active, ...status.stale] + .filter((entry) => entry.pinned !== pinned) + .map((entry) => entry.directoryName) + .filter((directoryName) => directoryName.startsWith(partialArg)); + } catch { + return []; + } + }, + }; +} + +const pinAutoSkillCommand = pinCommand('pin', true); +const unpinAutoSkillCommand = pinCommand('unpin', false); + +export const curatorCommand: SlashCommand = { + name: 'curator', + get description() { + return t('Maintain project auto-skills based on recent use.'); + }, + argumentHint: + '[status|run [--dry-run]|pin |unpin |restore ]', + kind: CommandKind.BUILT_IN, + supportedModes: ['interactive', 'non_interactive', 'acp'] as const, + action: statusAction, + subCommands: [ + statusCommand, + runCommand, + pinAutoSkillCommand, + unpinAutoSkillCommand, + restoreCommand, + ], +}; diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts index 11a6dab6588..cebfc00da7a 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts @@ -35,17 +35,22 @@ import { recordSkillInvocation, } from '@qwen-code/qwen-code-core'; -const { logSlashCommand, recordSkillInvocationMock, debugLoggerMock } = - vi.hoisted(() => ({ - logSlashCommand: vi.fn(), - recordSkillInvocationMock: vi.fn(), - debugLoggerMock: { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }, - })); +const { + logSlashCommand, + recordSkillInvocationMock, + recordAutoSkillUsageMock, + debugLoggerMock, +} = vi.hoisted(() => ({ + logSlashCommand: vi.fn(), + recordSkillInvocationMock: vi.fn(), + recordAutoSkillUsageMock: vi.fn(), + debugLoggerMock: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { const original = @@ -54,6 +59,7 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { ...original, logSlashCommand, recordSkillInvocation: recordSkillInvocationMock, + recordAutoSkillUsage: recordAutoSkillUsageMock, createDebugLogger: () => debugLoggerMock, getIdeInstaller: vi.fn().mockReturnValue(null), }; @@ -2259,9 +2265,17 @@ describe('useSlashCommandProcessor', () => { }); it('records successful skill slash commands when they submit a prompt', async () => { + vi.spyOn(mockConfig, 'getProjectRoot').mockReturnValueOnce( + '/test/project', + ); const skillCmd = createTestCommand( { name: 'review-skill', + skillDetail: { + name: 'review-skill', + level: 'project', + filePath: '/test/project/.qwen/skills/auto-skill-review/SKILL.md', + }, action: vi.fn().mockResolvedValue({ type: 'submit_prompt', content: [{ text: 'skill body' }], @@ -2282,6 +2296,11 @@ describe('useSlashCommandProcessor', () => { skillName: 'review-skill', success: true, }); + expect(recordAutoSkillUsageMock).toHaveBeenCalledWith('/test/project', { + name: 'review-skill', + level: 'project', + filePath: '/test/project/.qwen/skills/auto-skill-review/SKILL.md', + }); }); it('records failed skill slash commands when the action throws', async () => { @@ -2305,6 +2324,7 @@ describe('useSlashCommandProcessor', () => { skillName: 'review-skill', success: false, }); + expect(recordAutoSkillUsageMock).not.toHaveBeenCalled(); }); it('records blocked skill slash commands as failures', async () => { @@ -2338,9 +2358,13 @@ describe('useSlashCommandProcessor', () => { skillName: 'review-skill', success: false, }); + expect(recordAutoSkillUsageMock).not.toHaveBeenCalled(); }); it('records confirmed skill slash commands only once', async () => { + vi.spyOn(mockConfig, 'getProjectRoot').mockReturnValueOnce( + '/test/project', + ); const action = vi .fn() .mockResolvedValueOnce({ @@ -2355,6 +2379,11 @@ describe('useSlashCommandProcessor', () => { const skillCmd = createTestCommand( { name: 'review-skill', + skillDetail: { + name: 'review-skill', + level: 'project', + filePath: '/test/project/.qwen/skills/auto-skill-review/SKILL.md', + }, action, }, CommandKind.SKILL, @@ -2379,6 +2408,7 @@ describe('useSlashCommandProcessor', () => { expect(action).toHaveBeenCalledTimes(2); }); expect(recordSkillInvocation).toHaveBeenCalledTimes(1); + expect(recordAutoSkillUsageMock).toHaveBeenCalledTimes(1); expect(recordSkillInvocation).toHaveBeenCalledWith(mockConfig, { skillName: 'review-skill', success: true, @@ -2589,6 +2619,45 @@ describe('useSlashCommandProcessor', () => { expect(recordedNames).toContain('bugfix'); }); + it('records successful stacked project auto-skills as used', async () => { + vi.spyOn(mockConfig, 'getProjectRoot').mockReturnValue('/test/project'); + const skillA = { + ...createSkillCommand('feat-dev', 'a'), + skillDetail: { + name: 'feat-dev', + level: 'project', + filePath: '/test/project/.qwen/skills/auto-skill-feat-dev/SKILL.md', + }, + }; + const skillB = { + ...createSkillCommand('review', 'b'), + skillDetail: { + name: 'review', + level: 'project', + filePath: '/test/project/.qwen/skills/auto-skill-review/SKILL.md', + }, + }; + const result = setupProcessorHook([skillA, skillB]); + await waitFor(() => expect(result.current.slashCommands).toHaveLength(2)); + + recordAutoSkillUsageMock.mockClear(); + await act(async () => { + await result.current.handleSlashCommand('/feat-dev /review do stuff'); + }); + + expect(recordAutoSkillUsageMock).toHaveBeenCalledTimes(2); + expect(recordAutoSkillUsageMock).toHaveBeenCalledWith('/test/project', { + name: 'feat-dev', + level: 'project', + filePath: '/test/project/.qwen/skills/auto-skill-feat-dev/SKILL.md', + }); + expect(recordAutoSkillUsageMock).toHaveBeenCalledWith('/test/project', { + name: 'review', + level: 'project', + filePath: '/test/project/.qwen/skills/auto-skill-review/SKILL.md', + }); + }); + it('appends remaining text after all skill bodies', async () => { const skillA = createSkillCommand('feat-dev', 'a'); const skillB = createSkillCommand('review', 'b'); diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index 862b7905500..e0fe55e7d7f 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -54,7 +54,10 @@ import { BundledSkillLoader } from '../../services/BundledSkillLoader.js'; import { FileCommandLoader } from '../../services/FileCommandLoader.js'; import { SavedWorkflowLoader } from '../../services/saved-workflow-loader.js'; import { McpPromptLoader } from '../../services/McpPromptLoader.js'; -import { SkillCommandLoader } from '../../services/SkillCommandLoader.js'; +import { + recordAutoSkillCommandUsage, + SkillCommandLoader, +} from '../../services/SkillCommandLoader.js'; import { parseSlashCommand, parseStackedSlashCommands, @@ -930,10 +933,14 @@ export const useSlashCommandProcessor = ( } if (config) { + const succeeded = skillResult?.type === 'submit_prompt'; recordSkillInvocation(config, { skillName: getSkillCommandName(skill), - success: skillResult?.type === 'submit_prompt', + success: succeeded, }); + if (succeeded) { + void recordAutoSkillCommandUsage(config, skill); + } } } @@ -1256,6 +1263,7 @@ export const useSlashCommandProcessor = ( updateItem(invocationItemId, { sentToModel: true }); } recordSkillCommandInvocation(true); + void recordAutoSkillCommandUsage(config, commandToExecute); return { type: 'submit_prompt', content, diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index c4a6003fe5d..f2f88a231ae 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -83,6 +83,7 @@ import { getTeamMemoryShareabilityWarning } from '../memory/team-memory-git-stat import * as runtimeStatus from '../utils/runtimeStatus.js'; import { ExtensionManager } from '../extension/extensionManager.js'; import { SkillManager } from '../skills/skill-manager.js'; +import { maybeRunAutoSkillCurator } from '../skills/skill-curator.js'; import { HookSystem } from '../hooks/index.js'; import { GOAL_HOOK_ID_OUTPUT_KEY } from '../goals/goalHook.js'; import type { FileHistorySnapshot } from '../services/fileHistoryService.js'; @@ -205,6 +206,9 @@ vi.mock('../memory/team-memory-sync.js', () => ({ .fn() .mockResolvedValue({ committed: false, pulled: false, pushed: false }), })); +vi.mock('../skills/skill-curator.js', () => ({ + maybeRunAutoSkillCurator: vi.fn().mockResolvedValue({ status: 'not_due' }), +})); vi.mock('../memory/team-memory-git-status.js', () => ({ getTeamMemoryShareabilityWarning: vi.fn().mockReturnValue(null), })); @@ -3119,6 +3123,44 @@ describe('Server Config (config.ts)', () => { ).toEqual([initializationError, closeError]); }); + it('runs due auto-skill curation before loading skills when enabled', async () => { + const config = new Config({ ...baseParams, enableAutoSkill: true }); + + await config.initialize(); + + expect(maybeRunAutoSkillCurator).toHaveBeenCalledWith(TARGET_DIR); + expect( + vi.mocked(maybeRunAutoSkillCurator).mock.invocationCallOrder[0], + ).toBeLessThan(vi.mocked(SkillManager).mock.invocationCallOrder[0]); + }); + + it('does not run auto-skill curation when auto-skill is disabled', async () => { + await new Config({ ...baseParams, enableAutoSkill: false }).initialize(); + + expect(maybeRunAutoSkillCurator).not.toHaveBeenCalled(); + }); + + it('does not run auto-skill curation in an untrusted folder', async () => { + await new Config({ + ...baseParams, + enableAutoSkill: true, + trustedFolder: false, + }).initialize(); + + expect(maybeRunAutoSkillCurator).not.toHaveBeenCalled(); + }); + + it('continues loading skills when auto-skill curation fails', async () => { + vi.mocked(maybeRunAutoSkillCurator).mockRejectedValueOnce( + new Error('corrupt curator state'), + ); + + const config = new Config({ ...baseParams, enableAutoSkill: true }); + + await expect(config.initialize()).resolves.toBeUndefined(); + expect(SkillManager).toHaveBeenCalledTimes(1); + }); + it('waits for in-flight initialization before cleaning late resources', async () => { const config = new Config(baseParams); let releaseInitialization!: () => void; diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 86274d7644d..b2affebba07 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -98,6 +98,7 @@ import { InputFormat, OutputFormat } from '../output/types.js'; import { PromptRegistry } from '../prompts/prompt-registry.js'; import { ResourceRegistry } from '../resources/resource-registry.js'; import { SkillManager } from '../skills/skill-manager.js'; +import { maybeRunAutoSkillCurator } from '../skills/skill-curator.js'; import type { SkillLevel } from '../skills/types.js'; import { PermissionManager } from '../permissions/permission-manager.js'; import { @@ -2861,6 +2862,22 @@ export class Config { this.subagentManager = new SubagentManager(this); recordStartupEvent('config_initialize_skills_start'); if (!options?.skipSkillManager) { + if (this.getAutoSkillEnabled() && this.isTrustedFolder()) { + try { + const curatorResult = await maybeRunAutoSkillCurator( + this.getProjectRoot(), + ); + if (curatorResult.status === 'ran') { + this.debugLogger.debug( + `Auto-skill curator checked ${curatorResult.result.checked} skill(s) and archived ${curatorResult.result.archived.length}.`, + ); + } + } catch (error) { + this.debugLogger.warn( + `Auto-skill curator skipped: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } this.skillManager = new SkillManager(this); if (this.getBareMode() || this.isSafeMode()) { await this.skillManager.refreshCache(); diff --git a/packages/core/src/memory/manager.test.ts b/packages/core/src/memory/manager.test.ts index dd5f66dfc0c..e7ccbacc93e 100644 --- a/packages/core/src/memory/manager.test.ts +++ b/packages/core/src/memory/manager.test.ts @@ -418,6 +418,39 @@ describe('MemoryManager', () => { await expect(fs.access(skillFilePath)).rejects.toThrow(); }); + it('stages a new skill whose name exists only in the archive', async () => { + const archivedManifest = path.join( + projectRoot, + '.qwen', + 'archived-skills', + 'auto-skill-foo', + 'SKILL.md', + ); + await fs.mkdir(path.dirname(archivedManifest), { recursive: true }); + await fs.writeFile(archivedManifest, 'archived'); + const mgr = new MemoryManager(); + const record = await mgr.scheduleSkillReview({ + projectRoot, + sessionId: 'sess', + history: [{ role: 'user', parts: [{ text: 'hi' }] }], + toolCallCount: 25, + threshold: 2, + skillsModified: false, + config: makeMockConfig(), + confirmBeforePersist: true, + }).promise!; + + const pendingSkills = record.metadata?.['pendingSkills'] as Array<{ + stagedManifestPath: string; + }>; + expect(pendingSkills).toHaveLength(1); + await expect(fs.access(skillFilePath)).rejects.toThrow(); + await expect( + fs.access(pendingSkills[0]!.stagedManifestPath), + ).resolves.toBeUndefined(); + await expect(fs.access(archivedManifest)).resolves.toBeUndefined(); + }); + it('leaves the skill in place and sets no pendingSkills when confirmBeforePersist is false', async () => { const mgr = new MemoryManager(); const result = mgr.scheduleSkillReview({ diff --git a/packages/core/src/memory/skillReviewAgentPlanner.test.ts b/packages/core/src/memory/skillReviewAgentPlanner.test.ts index ec60be6d377..b5c7fa5bce5 100644 --- a/packages/core/src/memory/skillReviewAgentPlanner.test.ts +++ b/packages/core/src/memory/skillReviewAgentPlanner.test.ts @@ -143,6 +143,50 @@ describe('skillReviewAgentPlanner — write_file collision deny (#4437)', () => expect(decision).toBe('allow'); }); + it('denies write_file when the directory name is already archived', async () => { + const directoryName = 'auto-skill-retired'; + await fs.mkdir( + path.join(projectRoot, '.qwen', 'archived-skills', directoryName), + { recursive: true }, + ); + const target = path.join( + projectRoot, + '.qwen', + 'skills', + directoryName, + 'SKILL.md', + ); + + expect( + await scopedPm(projectRoot).evaluate({ + toolName: ToolNames.WRITE_FILE, + filePath: target, + }), + ).toBe('deny'); + }); + + it('denies edit creation when the directory name is already archived', async () => { + const directoryName = 'auto-skill-retired'; + await fs.mkdir( + path.join(projectRoot, '.qwen', 'archived-skills', directoryName), + { recursive: true }, + ); + const target = path.join( + projectRoot, + '.qwen', + 'skills', + directoryName, + 'SKILL.md', + ); + + expect( + await scopedPm(projectRoot).evaluate({ + toolName: ToolNames.EDIT, + filePath: target, + }), + ).toBe('deny'); + }); + it('still allows edit on an existing auto-skill (update path preserved)', async () => { const filePath = await writeSkillFile(projectRoot, 'my-skill', AUTO_SKILL); const pm = scopedPm(projectRoot); @@ -292,6 +336,21 @@ describe('listExistingSkillDirNames', () => { ]); }); + it('does not treat archive-only directory names as live skills', async () => { + const archived = path.join( + projectRoot, + '.qwen', + 'archived-skills', + 'auto-skill-retired', + ); + await fs.mkdir(archived, { recursive: true }); + await writeSkillFile(projectRoot, 'auto-skill-live', AUTO_SKILL); + + expect(await listExistingSkillDirNames(projectRoot)).toEqual([ + 'auto-skill-live', + ]); + }); + it('skips directories without SKILL.md so half-built dirs do not reserve names', async () => { await writeSkillFile(projectRoot, 'real', AUTO_SKILL); await fs.mkdir(path.join(projectRoot, '.qwen', 'skills', 'empty'), { @@ -343,7 +402,30 @@ describe('buildTaskPrompt', () => { const prompt = await buildTaskPrompt(projectRoot); expect(prompt).toContain('alpha'); expect(prompt).toContain('beta'); - expect(prompt).toMatch(/do NOT reuse/i); + expect(prompt).toMatch(/Active skill directory names/i); + }); + + it('lists archived directory names as reserved', async () => { + const directoryName = 'auto-skill-retired'; + await fs.mkdir( + path.join(projectRoot, '.qwen', 'archived-skills', directoryName), + { recursive: true }, + ); + + expect(await buildTaskPrompt(projectRoot)).toContain(directoryName); + }); + + it('excludes archived directory names carrying control bytes from the prompt', async () => { + // Mirrors the curator's charset guard: a crafted archived directory name + // with ANSI/control bytes must not reach the task prompt verbatim. + const directoryName = 'auto-skill-evil\u001b[31m'; + await fs.mkdir( + path.join(projectRoot, '.qwen', 'archived-skills', directoryName), + { recursive: true }, + ); + + const prompt = await buildTaskPrompt(projectRoot); + expect(prompt).not.toContain('\u001b[31m'); }); it('falls back to a placeholder line when no skills exist yet', async () => { diff --git a/packages/core/src/memory/skillReviewAgentPlanner.ts b/packages/core/src/memory/skillReviewAgentPlanner.ts index fb963c85aa1..eb9cf4c7147 100644 --- a/packages/core/src/memory/skillReviewAgentPlanner.ts +++ b/packages/core/src/memory/skillReviewAgentPlanner.ts @@ -18,10 +18,12 @@ import { buildFunctionResponseParts } from '../tools/agent/fork-subagent.js'; import { ToolNames } from '../tools/tool-names.js'; import { assertRealProjectSkillPath, + getArchivedSkillsRoot, getProjectSkillsRoot, isProjectSkillPath, SKILL_FILE_NAME, } from '../skills/skill-paths.js'; +import { SKILL_NAME_PATTERN } from '../skills/types.js'; export const SKILL_REVIEW_AGENT_NAME = 'managed-skill-extractor' as const; export const DEFAULT_AUTO_SKILL_MAX_TURNS = 8; @@ -79,6 +81,21 @@ async function hasAutoSkillSource(filePath: string): Promise { return /^source:\s*auto-skill\s*$/m.test(match[1]); } +async function isArchivedSkillDirectoryReserved( + filePath: string, + projectRoot: string, +): Promise { + const directoryName = path.basename(path.dirname(filePath)); + try { + await fs.lstat( + path.join(getArchivedSkillsRoot(projectRoot), directoryName), + ); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code !== 'ENOENT'; + } +} + function isScopedTool(toolName: string): boolean { return ( toolName === ToolNames.READ_FILE || @@ -137,7 +154,10 @@ async function evaluateScopedDecision( // For existing files, verify source: auto-skill is present. const sourceFlag = await hasAutoSkillSource(ctx.filePath); if (sourceFlag === null) { - // File does not exist yet — allow creation (path already validated above). + if (await isArchivedSkillDirectoryReserved(ctx.filePath, projectRoot)) { + return 'deny'; + } + // File does not exist yet and the directory name is not archived. return 'allow'; } return sourceFlag ? 'allow' : 'deny'; @@ -168,6 +188,9 @@ async function evaluateScopedDecision( } catch { return 'deny'; } + if (await isArchivedSkillDirectoryReserved(ctx.filePath, projectRoot)) { + return 'deny'; + } // ENOENT → file does not exist → allow creation. // Anything else (file present, EACCES, EISDIR, ...) → deny so we // never overwrite something we cannot prove is safe to clobber. @@ -282,13 +305,11 @@ function buildAgentHistory(history: Content[]): Content[] { } /** - * Enumerate directories under the project skills root that contain a - * SKILL.md. Returned names are the directory basenames (the same identifier - * the agent uses when picking `.qwen/skills//SKILL.md`). + * Enumerate active project skill directory names. * - * Best-effort: any read error (ENOENT, EACCES, ...) returns `[]` so a - * temporarily-unreadable skills dir downgrades to "no enumeration" rather - * than aborting the task. Exported for tests. + * Best-effort: an unreadable root contributes no names, so a temporary read + * failure downgrades enumeration rather than aborting the task. Exported for + * tests. */ export async function listExistingSkillDirNames( projectRoot: string, @@ -315,8 +336,32 @@ export async function listExistingSkillDirNames( // shouldn't reserve a name. } } - names.sort(); - return names; + return names.sort(); +} + +async function listArchivedSkillDirNames( + projectRoot: string, +): Promise { + const names: string[] = []; + try { + const entries = await fs.readdir(getArchivedSkillsRoot(projectRoot), { + withFileTypes: true, + }); + for (const entry of entries) { + // Apply the same charset guard the curator uses everywhere else so a + // crafted archived directory name carrying ANSI/control bytes cannot + // reach the task prompt verbatim. + if ( + (entry.isDirectory() || entry.isSymbolicLink()) && + SKILL_NAME_PATTERN.test(entry.name) + ) { + names.push(entry.name); + } + } + } catch { + // An unavailable archive contributes no reserved names. + } + return names.sort(); } /** @@ -330,11 +375,23 @@ export async function listExistingSkillDirNames( */ export async function buildTaskPrompt(projectRoot: string): Promise { const skillsRoot = getProjectSkillsRoot(projectRoot); - const existing = await listExistingSkillDirNames(projectRoot); + const [active, archived] = await Promise.all([ + listExistingSkillDirNames(projectRoot), + listArchivedSkillDirNames(projectRoot), + ]); const existingLine = - existing.length === 0 + active.length === 0 && archived.length === 0 ? '(no skills exist yet — any name is available)' - : `Existing skill names (do NOT reuse for write_file; use \`edit\` if you want to update one of these): ${existing.join(', ')}`; + : [ + active.length > 0 + ? `Active skill directory names (use \`edit\` to update): ${active.join(', ')}` + : undefined, + archived.length > 0 + ? `Archived skill directory names (do NOT reuse for write_file): ${archived.join(', ')}` + : undefined, + ] + .filter(Boolean) + .join('\n'); return [ `Project skills directory: \`${skillsRoot}\``, '', diff --git a/packages/core/src/skills/index.ts b/packages/core/src/skills/index.ts index e563cf9588e..2d945b4b9cd 100644 --- a/packages/core/src/skills/index.ts +++ b/packages/core/src/skills/index.ts @@ -42,3 +42,6 @@ export { SkillActivationRegistry, splitConditionalSkills, } from './skill-activation.js'; + +// Project auto-skill lifecycle maintenance +export * from './skill-curator.js'; diff --git a/packages/core/src/skills/skill-curator.reread.test.ts b/packages/core/src/skills/skill-curator.reread.test.ts new file mode 100644 index 00000000000..9497716f159 --- /dev/null +++ b/packages/core/src/skills/skill-curator.reread.test.ts @@ -0,0 +1,125 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { recordAutoSkillUsage, runAutoSkillCurator } from './skill-curator.js'; + +// The archive guard re-reads the manifest just before moving it, so activity +// that lands between the initial scan and that re-read downgrades the skill +// instead of archiving it. Exercising that branch with a real race would be +// flaky, so only `open` is mocked (every other fs call stays real, keeping the +// temp-dir fixtures working): while `gate.active`, the second open of the +// target manifest first bumps its mtime — a concurrent edit — so the re-read +// sees fresher activity than the scan did. +const gate = vi.hoisted(() => ({ + active: false, + opens: 0, + target: '', + fresh: new Date(0), +})); + +vi.mock('node:fs/promises', async (importActual) => { + const actual = await importActual(); + type OpenParams = Parameters; + return { + ...actual, + open: vi.fn( + async ( + filePath: OpenParams[0], + flags?: OpenParams[1], + mode?: OpenParams[2], + ) => { + if (gate.active && filePath === gate.target) { + gate.opens += 1; + if (gate.opens >= 2) { + await actual.utimes(filePath, gate.fresh, gate.fresh); + } + } + return actual.open(filePath, flags, mode); + }, + ), + }; +}); + +const DAY_MS = 24 * 60 * 60 * 1000; + +describe('auto-skill curator archive re-read guard', () => { + let projectRoot: string; + + beforeEach(async () => { + projectRoot = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-skill-curator-reread-'), + ); + }); + + afterEach(async () => { + gate.active = false; + gate.opens = 0; + await fs.rm(projectRoot, { recursive: true, force: true }); + }); + + async function writeSkill( + directoryName: string, + modifiedAt: Date, + ): Promise { + const directory = path.join(projectRoot, '.qwen', 'skills', directoryName); + const manifest = path.join(directory, 'SKILL.md'); + await fs.mkdir(directory, { recursive: true }); + await fs.writeFile( + manifest, + [ + '---', + `name: ${directoryName.replace(/^auto-skill-/, '')}`, + `description: ${directoryName}`, + 'source: auto-skill', + '---', + '', + '# Skill', + ].join('\n'), + ); + await fs.utimes(manifest, modifiedAt, modifiedAt); + return manifest; + } + + it('downgrades to stale instead of archiving when the re-read is fresher', async () => { + const now = new Date('2026-07-27T00:00:00.000Z'); + const old = new Date(now.getTime() - 100 * DAY_MS); + const manifest = await writeSkill('auto-skill-reread', old); + await recordAutoSkillUsage( + projectRoot, + { name: 'reread', level: 'project', filePath: manifest }, + old, + ); + + // The scan sees the 100-day-old mtime (archive-eligible); the re-read sees + // activity 40 days ago — inside the 90-day archive window but past the + // 30-day stale threshold, so the skill must be marked stale, not archived. + gate.target = manifest; + gate.fresh = new Date(now.getTime() - 40 * DAY_MS); + gate.opens = 0; + gate.active = true; + + const result = await runAutoSkillCurator(projectRoot, { now }); + + expect(result.archived).toEqual([]); + expect(result.markedStale).toEqual(['auto-skill-reread']); + await expect(fs.access(manifest)).resolves.toBeUndefined(); + await expect( + fs.access( + path.join( + projectRoot, + '.qwen', + 'archived-skills', + 'auto-skill-reread', + 'SKILL.md', + ), + ), + ).rejects.toMatchObject({ code: 'ENOENT' }); + }); +}); diff --git a/packages/core/src/skills/skill-curator.rollback.test.ts b/packages/core/src/skills/skill-curator.rollback.test.ts new file mode 100644 index 00000000000..a899ad591ce --- /dev/null +++ b/packages/core/src/skills/skill-curator.rollback.test.ts @@ -0,0 +1,304 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as atomicFileWrite from '../utils/atomicFileWrite.js'; +import { + recordAutoSkillUsage, + restoreArchivedAutoSkill, + runAutoSkillCurator, +} from './skill-curator.js'; + +// Wrap atomicWriteJSON so it delegates to the real implementation by default +// (seeding and normal writes still persist) but can be forced to fail once, +// after a real archive move, to exercise the rollback recovery path. +vi.mock('../utils/atomicFileWrite.js', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + atomicWriteJSON: vi.fn(actual.atomicWriteJSON), + }; +}); + +const DAY_MS = 24 * 60 * 60 * 1000; + +describe('auto-skill curator rollback', () => { + let projectRoot: string; + + beforeEach(async () => { + projectRoot = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-skill-curator-rollback-'), + ); + }); + + afterEach(async () => { + vi.resetAllMocks(); + await fs.rm(projectRoot, { recursive: true, force: true }); + }); + + async function writeSkill( + directoryName: string, + modifiedAt: Date, + ): Promise { + const directory = path.join(projectRoot, '.qwen', 'skills', directoryName); + const manifest = path.join(directory, 'SKILL.md'); + await fs.mkdir(directory, { recursive: true }); + await fs.writeFile( + manifest, + [ + '---', + `name: ${directoryName.replace(/^auto-skill-/, '')}`, + `description: ${directoryName}`, + 'source: auto-skill', + '---', + '', + '# Skill', + ].join('\n'), + ); + await fs.utimes(manifest, modifiedAt, modifiedAt); + return manifest; + } + + it('rolls back an archive move when persisting state fails', async () => { + const now = new Date('2026-07-27T00:00:00.000Z'); + const old = new Date(now.getTime() - 100 * DAY_MS); + const manifest = await writeSkill('auto-skill-old', old); + // Seeding uses the real atomicWriteJSON (default passthrough). + await recordAutoSkillUsage( + projectRoot, + { name: 'old', level: 'project', filePath: manifest }, + old, + ); + + const liveManifest = path.join( + projectRoot, + '.qwen', + 'skills', + 'auto-skill-old', + 'SKILL.md', + ); + const archivedManifest = path.join( + projectRoot, + '.qwen', + 'archived-skills', + 'auto-skill-old', + 'SKILL.md', + ); + + // Fail the single state write that runs after the archive rename. + vi.mocked(atomicFileWrite.atomicWriteJSON).mockRejectedValueOnce( + new Error('simulated persistence failure'), + ); + + await expect(runAutoSkillCurator(projectRoot, { now })).rejects.toThrow( + 'simulated persistence failure', + ); + + // The rename was rolled back: the skill is back in the live library and is + // not left stranded in the archive. + await expect(fs.access(liveManifest)).resolves.toBeUndefined(); + await expect(fs.access(archivedManifest)).rejects.toMatchObject({ + code: 'ENOENT', + }); + }); + + it('rolls back every archive move when persisting state fails', async () => { + const now = new Date('2026-07-27T00:00:00.000Z'); + const old = new Date(now.getTime() - 100 * DAY_MS); + const directoryNames = ['auto-skill-old-one', 'auto-skill-old-two']; + + for (const directoryName of directoryNames) { + const manifest = await writeSkill(directoryName, old); + await recordAutoSkillUsage( + projectRoot, + { + name: directoryName.replace(/^auto-skill-/, ''), + level: 'project', + filePath: manifest, + }, + old, + ); + } + + vi.mocked(atomicFileWrite.atomicWriteJSON).mockRejectedValueOnce( + new Error('simulated persistence failure'), + ); + + await expect(runAutoSkillCurator(projectRoot, { now })).rejects.toThrow( + 'simulated persistence failure', + ); + + for (const directoryName of directoryNames) { + const liveManifest = path.join( + projectRoot, + '.qwen', + 'skills', + directoryName, + 'SKILL.md', + ); + const archivedManifest = path.join( + projectRoot, + '.qwen', + 'archived-skills', + directoryName, + 'SKILL.md', + ); + await expect(fs.access(liveManifest)).resolves.toBeUndefined(); + await expect(fs.access(archivedManifest)).rejects.toMatchObject({ + code: 'ENOENT', + }); + } + }); + + it('rolls back a restore move when persisting state fails', async () => { + const now = new Date('2026-07-27T00:00:00.000Z'); + const old = new Date(now.getTime() - 100 * DAY_MS); + const manifest = await writeSkill('auto-skill-old', old); + await recordAutoSkillUsage( + projectRoot, + { name: 'old', level: 'project', filePath: manifest }, + old, + ); + // Archive the skill so there is an archived copy to restore. + await runAutoSkillCurator(projectRoot, { now }); + + const liveManifest = path.join( + projectRoot, + '.qwen', + 'skills', + 'auto-skill-old', + 'SKILL.md', + ); + const archivedManifest = path.join( + projectRoot, + '.qwen', + 'archived-skills', + 'auto-skill-old', + 'SKILL.md', + ); + await expect(fs.access(archivedManifest)).resolves.toBeUndefined(); + + // Fail the single state write that runs after the restore rename. + vi.mocked(atomicFileWrite.atomicWriteJSON).mockRejectedValueOnce( + new Error('simulated persistence failure'), + ); + + await expect( + restoreArchivedAutoSkill(projectRoot, 'auto-skill-old', now), + ).rejects.toThrow('simulated persistence failure'); + + // The rename was rolled back: the skill is back in the archive and is not + // left stranded in the live library without a state record. + await expect(fs.access(archivedManifest)).resolves.toBeUndefined(); + await expect(fs.access(liveManifest)).rejects.toMatchObject({ + code: 'ENOENT', + }); + }); + + it('continues rolling back after an archive rollback fails', async () => { + const now = new Date('2026-07-27T00:00:00.000Z'); + const old = new Date(now.getTime() - 100 * DAY_MS); + const restoredManifest = await writeSkill('auto-skill-old-one', old); + const blockedManifest = await writeSkill('auto-skill-old-two', old); + await recordAutoSkillUsage( + projectRoot, + { name: 'old-one', level: 'project', filePath: restoredManifest }, + old, + ); + await recordAutoSkillUsage( + projectRoot, + { name: 'old-two', level: 'project', filePath: blockedManifest }, + old, + ); + const restoredLiveDirectory = path.dirname(restoredManifest); + const blockedLiveDirectory = path.dirname(blockedManifest); + const restoredArchivedDirectory = path.join( + projectRoot, + '.qwen', + 'archived-skills', + 'auto-skill-old-one', + ); + const blockedArchivedDirectory = path.join( + projectRoot, + '.qwen', + 'archived-skills', + 'auto-skill-old-two', + ); + const persistenceError = new Error('simulated persistence failure'); + vi.mocked(atomicFileWrite.atomicWriteJSON).mockImplementationOnce( + async () => { + await fs.mkdir(blockedLiveDirectory, { recursive: true }); + await fs.writeFile( + path.join(blockedLiveDirectory, 'rollback-blocker'), + 'x', + ); + throw persistenceError; + }, + ); + + await expect( + runAutoSkillCurator(projectRoot, { now }), + ).rejects.toMatchObject({ + message: expect.stringMatching(/^Rollback failed:/), + cause: persistenceError, + }); + await expect(fs.access(restoredLiveDirectory)).resolves.toBeUndefined(); + await expect(fs.access(restoredArchivedDirectory)).rejects.toMatchObject({ + code: 'ENOENT', + }); + await expect(fs.access(blockedArchivedDirectory)).resolves.toBeUndefined(); + await expect( + fs.access(path.join(blockedLiveDirectory, 'rollback-blocker')), + ).resolves.toBeUndefined(); + }); + + it('escalates when a restore move cannot be rolled back', async () => { + const now = new Date('2026-07-27T00:00:00.000Z'); + const old = new Date(now.getTime() - 100 * DAY_MS); + const manifest = await writeSkill('auto-skill-old', old); + await recordAutoSkillUsage( + projectRoot, + { name: 'old', level: 'project', filePath: manifest }, + old, + ); + await runAutoSkillCurator(projectRoot, { now }); + const liveDirectory = path.dirname(manifest); + const archivedDirectory = path.join( + projectRoot, + '.qwen', + 'archived-skills', + 'auto-skill-old', + ); + const persistenceError = new Error('simulated persistence failure'); + vi.mocked(atomicFileWrite.atomicWriteJSON).mockImplementationOnce( + async () => { + // The restore rename has completed by the time persistence starts. + // Recreate its source as a non-empty directory so rename-back fails. + await fs.mkdir(archivedDirectory, { recursive: true }); + await fs.writeFile( + path.join(archivedDirectory, 'rollback-blocker'), + 'x', + ); + throw persistenceError; + }, + ); + + await expect( + restoreArchivedAutoSkill(projectRoot, 'auto-skill-old', now), + ).rejects.toMatchObject({ + message: expect.stringMatching(/^Rollback failed:/), + cause: persistenceError, + }); + await expect(fs.access(liveDirectory)).resolves.toBeUndefined(); + await expect( + fs.access(path.join(archivedDirectory, 'rollback-blocker')), + ).resolves.toBeUndefined(); + }); +}); diff --git a/packages/core/src/skills/skill-curator.test.ts b/packages/core/src/skills/skill-curator.test.ts new file mode 100644 index 00000000000..ab8557809d0 --- /dev/null +++ b/packages/core/src/skills/skill-curator.test.ts @@ -0,0 +1,1006 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + AUTO_SKILL_ARCHIVE_AFTER_MS, + getAutoSkillCuratorStatus, + maybeRunAutoSkillCurator, + recordAutoSkillUsage, + restoreArchivedAutoSkill, + runAutoSkillCurator, + setAutoSkillPinned, +} from './skill-curator.js'; + +const DAY_MS = 24 * 60 * 60 * 1000; + +describe('auto-skill curator', () => { + let projectRoot: string; + + beforeEach(async () => { + projectRoot = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-skill-curator-'), + ); + }); + + afterEach(async () => { + await fs.rm(projectRoot, { recursive: true, force: true }); + }); + + async function writeSkill( + directoryName: string, + source: string, + modifiedAt: Date, + ): Promise { + const directory = path.join(projectRoot, '.qwen', 'skills', directoryName); + const manifest = path.join(directory, 'SKILL.md'); + await fs.mkdir(directory, { recursive: true }); + await fs.writeFile( + manifest, + [ + '---', + `name: ${directoryName.replace(/^auto-skill-/, '')}`, + `description: ${directoryName}`, + `source: ${source}`, + '---', + '', + '# Skill', + ].join('\n'), + ); + await fs.utimes(manifest, modifiedAt, modifiedAt); + return manifest; + } + + it('only manages doubly-marked project auto-skills', async () => { + const now = new Date('2026-07-27T00:00:00.000Z'); + const old = new Date(now.getTime() - 100 * DAY_MS); + const managedManifest = await writeSkill( + 'auto-skill-managed', + 'auto-skill', + old, + ); + await writeSkill('hand-authored', 'auto-skill', old); + await writeSkill('auto-skill-learned', 'learned', old); + await recordAutoSkillUsage( + projectRoot, + { name: 'managed', level: 'project', filePath: managedManifest }, + old, + ); + + const status = await getAutoSkillCuratorStatus(projectRoot, now); + + expect(status.stale.map((entry) => entry.directoryName)).toEqual([ + 'auto-skill-managed', + ]); + expect(status.active).toEqual([]); + }); + + it('does not leave a placeholder file beside the proper lock', async () => { + const now = new Date('2026-07-27T00:00:00.000Z'); + + await runAutoSkillCurator(projectRoot, { now }); + + await expect( + fs.lstat(path.join(projectRoot, '.qwen', 'skill-curator.lock')), + ).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('ignores auto-skill directories whose names carry control/ANSI bytes', async () => { + const now = new Date('2026-07-27T00:00:00.000Z'); + const old = new Date(now.getTime() - 100 * DAY_MS); + + // A crafted directory that satisfies the `auto-skill-` prefix and basename + // checks and carries a VALID frontmatter name, so only the directory-name + // charset guard can exclude it. Its name embeds an ESC control sequence + // that the non-interactive `/curator` output would otherwise print verbatim + // (terminal control-sequence injection). Keep a clean managed skill so the + // enumeration itself is exercised. + const maliciousDir = 'auto-skill-evil'; + const directory = path.join(projectRoot, '.qwen', 'skills', maliciousDir); + await fs.mkdir(directory, { recursive: true }); + const maliciousManifest = path.join(directory, 'SKILL.md'); + await fs.writeFile( + maliciousManifest, + [ + '---', + 'name: evil', + 'description: crafted', + 'source: auto-skill', + '---', + '', + '# Skill', + ].join('\n'), + ); + await fs.utimes(maliciousManifest, old, old); + + await writeSkill('auto-skill-clean', 'auto-skill', old); + + const status = await getAutoSkillCuratorStatus(projectRoot, now); + + const surfaced = [ + ...status.active, + ...status.stale, + ...status.archived, + ].map((entry) => entry.directoryName); + expect(surfaced).toContain('auto-skill-clean'); + expect(surfaced).not.toContain(maliciousDir); + }); + + it.skipIf(process.platform === 'win32')( + 'refuses an auto-skill whose manifest is a symlink', + async () => { + const now = new Date('2026-07-27T00:00:00.000Z'); + const old = new Date(now.getTime() - 100 * DAY_MS); + + // A managed directory whose SKILL.md is a symlink (git mode 120000 + // survives a clone). Even though the link target is a valid auto-skill + // manifest, the O_NOFOLLOW read refuses to follow it — so the skill is + // not managed and a crafted target (e.g. /dev/zero) can never be read. + const target = await writeSkill('auto-skill-real', 'auto-skill', old); + const linkedDir = path.join( + projectRoot, + '.qwen', + 'skills', + 'auto-skill-linked', + ); + await fs.mkdir(linkedDir, { recursive: true }); + await fs.symlink(target, path.join(linkedDir, 'SKILL.md')); + + const status = await getAutoSkillCuratorStatus(projectRoot, now); + + const surfaced = [ + ...status.active, + ...status.stale, + ...status.archived, + ].map((entry) => entry.directoryName); + expect(surfaced).not.toContain('auto-skill-linked'); + // The real, non-symlinked skill is still enumerated normally. + expect(surfaced).toContain('auto-skill-real'); + }, + ); + + it('keeps dry-run non-mutating while reporting first-sight seeding', async () => { + const now = new Date('2026-07-27T00:00:00.000Z'); + await writeSkill( + 'auto-skill-old', + 'auto-skill', + new Date(now.getTime() - 100 * DAY_MS), + ); + + const result = await runAutoSkillCurator(projectRoot, { + dryRun: true, + now, + }); + + expect(result).toMatchObject({ + dryRun: true, + checked: 1, + seeded: ['auto-skill-old'], + archived: [], + }); + await expect( + fs.access(path.join(projectRoot, '.qwen', 'skill-curator.json')), + ).rejects.toMatchObject({ code: 'ENOENT' }); + await expect( + fs.access(path.join(projectRoot, '.qwen', 'archived-skills')), + ).rejects.toMatchObject({ code: 'ENOENT' }); + await expect( + fs.access( + path.join(projectRoot, '.qwen', 'skills', 'auto-skill-old', 'SKILL.md'), + ), + ).resolves.toBeUndefined(); + }); + + it('previews aged persisted candidates without changing state', async () => { + const now = new Date('2026-07-27T00:00:00.000Z'); + const old = new Date(now.getTime() - 100 * DAY_MS); + const manifest = await writeSkill('auto-skill-old', 'auto-skill', old); + await recordAutoSkillUsage( + projectRoot, + { name: 'old', level: 'project', filePath: manifest }, + old, + ); + const statePath = path.join(projectRoot, '.qwen', 'skill-curator.json'); + const before = await fs.readFile(statePath, 'utf8'); + + const result = await runAutoSkillCurator(projectRoot, { + dryRun: true, + now, + }); + + expect(result.archived).toEqual(['auto-skill-old']); + expect(result.seeded).toEqual([]); + await expect(fs.readFile(statePath, 'utf8')).resolves.toBe(before); + await expect( + fs.access(path.join(projectRoot, '.qwen', 'archived-skills')), + ).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('seeds the first automatic observation before aging skills', async () => { + const now = new Date('2026-07-27T00:00:00.000Z'); + await writeSkill( + 'auto-skill-existing', + 'auto-skill', + new Date(now.getTime() - 200 * DAY_MS), + ); + + await expect(maybeRunAutoSkillCurator(projectRoot, now)).resolves.toEqual({ + status: 'seeded', + checked: 1, + }); + await expect( + fs.access( + path.join(projectRoot, '.qwen', 'skills', 'auto-skill-existing'), + ), + ).resolves.toBeUndefined(); + await expect( + maybeRunAutoSkillCurator( + projectRoot, + new Date(now.getTime() + 6 * DAY_MS), + ), + ).resolves.toEqual({ status: 'not_due' }); + + const later = new Date(now.getTime() + 91 * DAY_MS); + const result = await maybeRunAutoSkillCurator(projectRoot, later); + expect(result.status).toBe('ran'); + if (result.status === 'ran') { + expect(result.result.archived).toEqual(['auto-skill-existing']); + } + }); + + it('preserves an existing usage baseline when seeding the first run', async () => { + const usedAt = new Date('2026-04-01T00:00:00.000Z'); + const manifest = await writeSkill( + 'auto-skill-preseeded', + 'auto-skill', + new Date(usedAt.getTime() - 200 * DAY_MS), + ); + // Usage recorded before the first curator run establishes the inactivity + // baseline (firstSeenAt / lastActivityAt) at usedAt. + await recordAutoSkillUsage( + projectRoot, + { name: 'preseeded', level: 'project', filePath: manifest }, + usedAt, + ); + + // The first automatic (seeding) run happens ~40 days later. It must not + // reset the inactivity clock to `now`, or the stale transition would be + // delayed by up to the full interval. + const seedAt = new Date(usedAt.getTime() + 40 * DAY_MS); + await expect( + maybeRunAutoSkillCurator(projectRoot, seedAt), + ).resolves.toEqual({ status: 'seeded', checked: 1 }); + + const state = JSON.parse( + await fs.readFile( + path.join(projectRoot, '.qwen', 'skill-curator.json'), + 'utf8', + ), + ) as { + skills: Record< + string, + { firstSeenAt: string; lastActivityAt: string; useCount: number } + >; + }; + const record = state.skills['auto-skill-preseeded']!; + expect(record.firstSeenAt).toBe(usedAt.toISOString()); + expect(record.lastActivityAt).toBe(usedAt.toISOString()); + expect(record.useCount).toBe(1); + }); + + it('marks inactive skills stale before archiving them', async () => { + const now = new Date('2026-07-27T00:00:00.000Z'); + const old = new Date(now.getTime() - 40 * DAY_MS); + const manifest = await writeSkill('auto-skill-stale', 'auto-skill', old); + await recordAutoSkillUsage( + projectRoot, + { name: 'stale', level: 'project', filePath: manifest }, + old, + ); + + const result = await runAutoSkillCurator(projectRoot, { now }); + + expect(result.markedStale).toEqual(['auto-skill-stale']); + expect(result.archived).toEqual([]); + await expect( + fs.access(path.join(projectRoot, '.qwen', 'skills', 'auto-skill-stale')), + ).resolves.toBeUndefined(); + }); + + it('archives stale packages and restores them without overwriting', async () => { + const now = new Date('2026-07-27T00:00:00.000Z'); + const manifest = await writeSkill( + 'auto-skill-old', + 'auto-skill', + new Date(now.getTime() - 100 * DAY_MS), + ); + await recordAutoSkillUsage( + projectRoot, + { name: 'old', level: 'project', filePath: manifest }, + new Date(now.getTime() - 100 * DAY_MS), + ); + const supportFile = path.join( + projectRoot, + '.qwen', + 'skills', + 'auto-skill-old', + 'references', + 'notes.md', + ); + await fs.mkdir(path.dirname(supportFile), { recursive: true }); + await fs.writeFile(supportFile, 'keep me'); + + const run = await runAutoSkillCurator(projectRoot, { now }); + expect(run.archived).toEqual(['auto-skill-old']); + await expect( + recordAutoSkillUsage( + projectRoot, + { name: 'old', level: 'project', filePath: manifest }, + now, + ), + ).resolves.toBe(false); + await expect( + fs.readFile( + path.join( + projectRoot, + '.qwen', + 'archived-skills', + 'auto-skill-old', + 'references', + 'notes.md', + ), + 'utf8', + ), + ).resolves.toBe('keep me'); + + await restoreArchivedAutoSkill(projectRoot, 'auto-skill-old', now); + await expect(fs.readFile(supportFile, 'utf8')).resolves.toBe('keep me'); + const status = await getAutoSkillCuratorStatus(projectRoot, now); + expect(status.active.map((entry) => entry.directoryName)).toEqual([ + 'auto-skill-old', + ]); + }); + + it('refuses to restore over an existing active directory', async () => { + const now = new Date('2026-07-27T00:00:00.000Z'); + const manifest = await writeSkill( + 'auto-skill-old', + 'auto-skill', + new Date(now.getTime() - 100 * DAY_MS), + ); + await recordAutoSkillUsage( + projectRoot, + { name: 'old', level: 'project', filePath: manifest }, + new Date(now.getTime() - 100 * DAY_MS), + ); + const run = await runAutoSkillCurator(projectRoot, { now }); + expect(run.archived).toEqual(['auto-skill-old']); + + // A new skill reclaims the archived directory name in the live library. + const reusedManifest = await writeSkill( + 'auto-skill-old', + 'auto-skill', + now, + ); + await fs.writeFile(reusedManifest, 'REUSED'); + + await expect( + restoreArchivedAutoSkill(projectRoot, 'auto-skill-old', now), + ).rejects.toThrow('an active directory already exists'); + + // Neither the reused active directory nor the archived copy is disturbed. + await expect(fs.readFile(reusedManifest, 'utf8')).resolves.toBe('REUSED'); + await expect( + fs.access( + path.join( + projectRoot, + '.qwen', + 'archived-skills', + 'auto-skill-old', + 'SKILL.md', + ), + ), + ).resolves.toBeUndefined(); + }); + + it('protects recently used skills and increments durable usage', async () => { + const usedAt = new Date('2026-07-27T00:00:00.000Z'); + const manifest = await writeSkill( + 'auto-skill-used', + 'auto-skill', + new Date(usedAt.getTime() - 200 * DAY_MS), + ); + + await expect( + recordAutoSkillUsage( + projectRoot, + { name: 'used', level: 'project', filePath: manifest }, + usedAt, + ), + ).resolves.toBe(true); + const run = await runAutoSkillCurator(projectRoot, { + now: new Date(usedAt.getTime() + AUTO_SKILL_ARCHIVE_AFTER_MS - DAY_MS), + }); + + expect(run.archived).toEqual([]); + const state = JSON.parse( + await fs.readFile( + path.join(projectRoot, '.qwen', 'skill-curator.json'), + 'utf8', + ), + ) as { skills: Record }; + expect(state.skills['auto-skill-used']!.firstSeenAt).toBe( + usedAt.toISOString(), + ); + const status = await getAutoSkillCuratorStatus( + projectRoot, + new Date(usedAt.getTime() + DAY_MS), + ); + expect(status.active[0]).toMatchObject({ + directoryName: 'auto-skill-used', + useCount: 1, + }); + }); + + it('treats a recent manifest edit as activity', async () => { + const old = new Date('2026-01-01T00:00:00.000Z'); + const now = new Date('2026-07-27T00:00:00.000Z'); + const manifest = await writeSkill('auto-skill-edited', 'auto-skill', old); + await recordAutoSkillUsage( + projectRoot, + { name: 'edited', level: 'project', filePath: manifest }, + old, + ); + await fs.utimes(manifest, now, now); + + const run = await runAutoSkillCurator(projectRoot, { now }); + + expect(run.archived).toEqual([]); + expect(run.reactivated).toEqual([]); + }); + + it('reactivates a stale skill once activity resumes', async () => { + const now = new Date('2026-07-27T00:00:00.000Z'); + const old = new Date(now.getTime() - 40 * DAY_MS); + const manifest = await writeSkill('auto-skill-revived', 'auto-skill', old); + await recordAutoSkillUsage( + projectRoot, + { name: 'revived', level: 'project', filePath: manifest }, + old, + ); + + const staleRun = await runAutoSkillCurator(projectRoot, { now }); + expect(staleRun.markedStale).toEqual(['auto-skill-revived']); + expect(staleRun.reactivated).toEqual([]); + + await fs.utimes(manifest, now, now); + const revivedRun = await runAutoSkillCurator(projectRoot, { now }); + + expect(revivedRun.reactivated).toEqual(['auto-skill-revived']); + expect(revivedRun.archived).toEqual([]); + expect(revivedRun.markedStale).toEqual([]); + }); + + it('fails closed on corrupt state without moving a skill', async () => { + const now = new Date('2026-07-27T00:00:00.000Z'); + const manifest = await writeSkill( + 'auto-skill-old', + 'auto-skill', + new Date(now.getTime() - 100 * DAY_MS), + ); + await fs.writeFile( + path.join(projectRoot, '.qwen', 'skill-curator.json'), + '{broken', + ); + + await expect(runAutoSkillCurator(projectRoot, { now })).rejects.toThrow( + 'Invalid auto-skill curator state', + ); + await expect(fs.access(manifest)).resolves.toBeUndefined(); + }); + + it('fails closed on corrupt state without restoring an archived skill', async () => { + const now = new Date('2026-07-27T00:00:00.000Z'); + const old = new Date(now.getTime() - 100 * DAY_MS); + const manifest = await writeSkill('auto-skill-old', 'auto-skill', old); + await recordAutoSkillUsage( + projectRoot, + { name: 'old', level: 'project', filePath: manifest }, + old, + ); + await runAutoSkillCurator(projectRoot, { now }); + const archivedManifest = path.join( + projectRoot, + '.qwen', + 'archived-skills', + 'auto-skill-old', + 'SKILL.md', + ); + await fs.writeFile( + path.join(projectRoot, '.qwen', 'skill-curator.json'), + '{broken', + ); + + await expect( + restoreArchivedAutoSkill(projectRoot, 'auto-skill-old', now), + ).rejects.toThrow('Invalid auto-skill curator state'); + await expect(fs.access(archivedManifest)).resolves.toBeUndefined(); + await expect( + fs.access( + path.join(projectRoot, '.qwen', 'skills', 'auto-skill-old', 'SKILL.md'), + ), + ).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('skips archive collisions while continuing with other packages', async () => { + const now = new Date('2026-07-27T00:00:00.000Z'); + const old = new Date(now.getTime() - 100 * DAY_MS); + const liveManifest = await writeSkill( + 'auto-skill-collision', + 'auto-skill', + old, + ); + const otherManifest = await writeSkill( + 'auto-skill-other', + 'auto-skill', + old, + ); + await recordAutoSkillUsage( + projectRoot, + { name: 'collision', level: 'project', filePath: liveManifest }, + old, + ); + await recordAutoSkillUsage( + projectRoot, + { name: 'other', level: 'project', filePath: otherManifest }, + old, + ); + const archivedDirectory = path.join( + projectRoot, + '.qwen', + 'archived-skills', + 'auto-skill-collision', + ); + await fs.mkdir(archivedDirectory, { recursive: true }); + await fs.writeFile(path.join(archivedDirectory, 'sentinel'), 'preserve'); + + const result = await runAutoSkillCurator(projectRoot, { now }); + + expect(result.skippedCollisions).toEqual(['auto-skill-collision']); + expect(result.archived).toEqual(['auto-skill-other']); + await expect(fs.access(liveManifest)).resolves.toBeUndefined(); + await expect(fs.access(otherManifest)).rejects.toMatchObject({ + code: 'ENOENT', + }); + await expect( + fs.readFile(path.join(archivedDirectory, 'sentinel'), 'utf8'), + ).resolves.toBe('preserve'); + }); + + it('reports archive collisions during a dry run', async () => { + const now = new Date('2026-07-27T00:00:00.000Z'); + const old = new Date(now.getTime() - 100 * DAY_MS); + const liveManifest = await writeSkill( + 'auto-skill-collision', + 'auto-skill', + old, + ); + await recordAutoSkillUsage( + projectRoot, + { name: 'collision', level: 'project', filePath: liveManifest }, + old, + ); + const archivedDirectory = path.join( + projectRoot, + '.qwen', + 'archived-skills', + 'auto-skill-collision', + ); + await fs.mkdir(archivedDirectory, { recursive: true }); + + const result = await runAutoSkillCurator(projectRoot, { + dryRun: true, + now, + }); + + expect(result.skippedCollisions).toEqual(['auto-skill-collision']); + expect(result.archived).toEqual([]); + await expect(fs.access(liveManifest)).resolves.toBeUndefined(); + }); + + it('seeds an unseen skill on an explicit run before aging it', async () => { + const now = new Date('2026-07-27T00:00:00.000Z'); + const manifest = await writeSkill( + 'auto-skill-legacy', + 'auto-skill', + new Date(now.getTime() - 200 * DAY_MS), + ); + + const result = await runAutoSkillCurator(projectRoot, { now }); + + expect(result.seeded).toEqual(['auto-skill-legacy']); + expect(result.archived).toEqual([]); + await expect(fs.access(manifest)).resolves.toBeUndefined(); + }); + + it('keeps pinned skills active until they are unpinned', async () => { + const now = new Date('2026-07-27T00:00:00.000Z'); + const old = new Date(now.getTime() - 100 * DAY_MS); + const manifest = await writeSkill( + 'auto-skill-important', + 'auto-skill', + old, + ); + await recordAutoSkillUsage( + projectRoot, + { name: 'important', level: 'project', filePath: manifest }, + old, + ); + + await setAutoSkillPinned(projectRoot, 'auto-skill-important', true, now); + const pinnedRun = await runAutoSkillCurator(projectRoot, { now }); + expect(pinnedRun.archived).toEqual([]); + expect( + (await getAutoSkillCuratorStatus(projectRoot, now)).active[0], + ).toMatchObject({ directoryName: 'auto-skill-important', pinned: true }); + + await setAutoSkillPinned(projectRoot, 'auto-skill-important', false, now); + const unpinnedRun = await runAutoSkillCurator(projectRoot, { now }); + expect(unpinnedRun.archived).toEqual(['auto-skill-important']); + }); + + it('loads version 1 state written before pinning was added', async () => { + const now = new Date('2026-07-27T00:00:00.000Z'); + const manifest = await writeSkill('auto-skill-legacy', 'auto-skill', now); + await recordAutoSkillUsage( + projectRoot, + { name: 'legacy', level: 'project', filePath: manifest }, + now, + ); + const statePath = path.join(projectRoot, '.qwen', 'skill-curator.json'); + const state = JSON.parse(await fs.readFile(statePath, 'utf8')) as { + skills: Record; + }; + delete state.skills['auto-skill-legacy']!.pinned; + await fs.writeFile(statePath, JSON.stringify(state)); + + const status = await getAutoSkillCuratorStatus(projectRoot, now); + + expect(status.active[0]).toMatchObject({ + directoryName: 'auto-skill-legacy', + pinned: false, + }); + }); + + it('ignores non-project usage records', async () => { + const now = new Date('2026-07-27T00:00:00.000Z'); + const manifest = await writeSkill('auto-skill-user', 'auto-skill', now); + + await expect( + recordAutoSkillUsage( + projectRoot, + { name: 'user', level: 'user', filePath: manifest }, + now, + ), + ).resolves.toBe(false); + }); + + it('ignores project usage records outside the skills root', async () => { + const now = new Date('2026-07-27T00:00:00.000Z'); + const outsideDirectory = path.join( + projectRoot, + '.qwen', + 'outside', + 'auto-skill-evil', + ); + const manifest = path.join(outsideDirectory, 'SKILL.md'); + await fs.mkdir(outsideDirectory, { recursive: true }); + await fs.writeFile( + manifest, + [ + '---', + 'name: evil', + 'description: outside the managed skills root', + 'source: auto-skill', + '---', + '', + ].join('\n'), + ); + + await expect( + recordAutoSkillUsage( + projectRoot, + { name: 'evil', level: 'project', filePath: manifest }, + now, + ), + ).resolves.toBe(false); + }); + + it('rejects archive directory traversal during restore', async () => { + const now = new Date('2026-07-27T00:00:00.000Z'); + const traversalTarget = path.join(projectRoot, '.qwen', 'outside'); + await fs.mkdir(path.join(projectRoot, '.qwen', 'archived-skills'), { + recursive: true, + }); + await fs.mkdir(traversalTarget, { recursive: true }); + await fs.writeFile( + path.join(traversalTarget, 'SKILL.md'), + [ + '---', + 'name: outside', + 'description: traversal target', + 'source: auto-skill', + '---', + '', + '# Outside', + ].join('\n'), + ); + + await expect( + restoreArchivedAutoSkill( + projectRoot, + 'auto-skill-placeholder/../../outside', + now, + ), + ).rejects.toThrow('Archived auto-skill not found'); + expect((await fs.lstat(traversalTarget)).isDirectory()).toBe(true); + }); + + it.skipIf(process.platform === 'win32')( + 'refuses a symlinked state file', + async () => { + const now = new Date('2026-07-27T00:00:00.000Z'); + await writeSkill( + 'auto-skill-old', + 'auto-skill', + new Date(now.getTime() - 100 * DAY_MS), + ); + const statePath = path.join(projectRoot, '.qwen', 'skill-curator.json'); + const external = path.join(projectRoot, 'external-state.json'); + await fs.writeFile(external, JSON.stringify({ version: 1, skills: {} })); + await fs.symlink(external, statePath); + + // The target is valid JSON, so this only rejects because the read path + // refuses to follow the symlink at all. + await expect(getAutoSkillCuratorStatus(projectRoot, now)).rejects.toThrow( + 'refuses unsafe path', + ); + }, + ); + + it('refuses a non-regular-file state file', async () => { + const now = new Date('2026-07-27T00:00:00.000Z'); + await writeSkill( + 'auto-skill-old', + 'auto-skill', + new Date(now.getTime() - 100 * DAY_MS), + ); + const statePath = path.join(projectRoot, '.qwen', 'skill-curator.json'); + await fs.mkdir(statePath, { recursive: true }); + + await expect( + runAutoSkillCurator(projectRoot, { dryRun: true, now }), + ).rejects.toThrow('refuses unsafe path'); + }); + + it('fails closed on an oversized state file', async () => { + const now = new Date('2026-07-27T00:00:00.000Z'); + await writeSkill( + 'auto-skill-old', + 'auto-skill', + new Date(now.getTime() - 100 * DAY_MS), + ); + const statePath = path.join(projectRoot, '.qwen', 'skill-curator.json'); + // A regular file just over the 1 MiB read cap. + await fs.writeFile( + statePath, + `{"version":1,"skills":{},"pad":"${'x'.repeat(1024 * 1024)}"}`, + ); + + await expect(getAutoSkillCuratorStatus(projectRoot, now)).rejects.toThrow( + 'Invalid auto-skill curator state', + ); + }); + + it('distinguishes a present but ineligible archived skill from a missing one', async () => { + const now = new Date('2026-07-27T00:00:00.000Z'); + const archivedDir = path.join( + projectRoot, + '.qwen', + 'archived-skills', + 'auto-skill-broken', + ); + await fs.mkdir(archivedDir, { recursive: true }); + // A manifest that lost its frontmatter is present but not eligible. + await fs.writeFile(path.join(archivedDir, 'SKILL.md'), '# no frontmatter'); + + await expect( + restoreArchivedAutoSkill(projectRoot, 'auto-skill-broken', now), + ).rejects.toThrow('is not an eligible managed skill'); + await expect( + restoreArchivedAutoSkill(projectRoot, 'auto-skill-absent', now), + ).rejects.toThrow('Archived auto-skill not found'); + }); + + it('clamps a future manifest mtime so the skill remains curatable', async () => { + const now = new Date('2026-07-27T00:00:00.000Z'); + const old = new Date(now.getTime() - 180 * DAY_MS); + const future = new Date(now.getTime() + 10 * 365 * DAY_MS); + const manifest = await writeSkill('auto-skill-future', 'auto-skill', old); + await recordAutoSkillUsage( + projectRoot, + { name: 'future', level: 'project', filePath: manifest }, + old, + ); + // Stamp the manifest far in the future (clock skew, backup restore). + await fs.utimes(manifest, future, future); + + const result = await runAutoSkillCurator(projectRoot, { now }); + + expect(result.archived).toEqual(['auto-skill-future']); + }); + + it('does not double-list a directory present in both live and archived roots', async () => { + const now = new Date('2026-07-27T00:00:00.000Z'); + const old = new Date(now.getTime() - 100 * DAY_MS); + const manifest = await writeSkill('auto-skill-dup', 'auto-skill', old); + await recordAutoSkillUsage( + projectRoot, + { name: 'dup', level: 'project', filePath: manifest }, + old, + ); + await runAutoSkillCurator(projectRoot, { now }); + + // Recreate the same directory name in the live library. + await writeSkill('auto-skill-dup', 'auto-skill', now); + + const status = await getAutoSkillCuratorStatus(projectRoot, now); + + const allNames = [ + ...status.active, + ...status.stale, + ...status.archived, + ].map((entry) => entry.directoryName); + const dupCount = allNames.filter((n) => n === 'auto-skill-dup').length; + expect(dupCount).toBe(1); + expect(status.active.map((e) => e.directoryName)).toContain( + 'auto-skill-dup', + ); + expect(status.archived.map((e) => e.directoryName)).not.toContain( + 'auto-skill-dup', + ); + }); + + it('isolates a per-skill rename failure and still persists state', async () => { + const now = new Date('2026-07-27T00:00:00.000Z'); + const old = new Date(now.getTime() - 100 * DAY_MS); + const manifestA = await writeSkill('auto-skill-a', 'auto-skill', old); + const manifestB = await writeSkill('auto-skill-b', 'auto-skill', old); + await recordAutoSkillUsage( + projectRoot, + { name: 'a', level: 'project', filePath: manifestA }, + old, + ); + await recordAutoSkillUsage( + projectRoot, + { name: 'b', level: 'project', filePath: manifestB }, + old, + ); + + // Make the archive root a file so rename into it fails for the first + // skill, then fix it so the second succeeds. + const archiveRoot = path.join(projectRoot, '.qwen', 'archived-skills'); + await fs.mkdir(archiveRoot, { recursive: true }); + // Block rename for auto-skill-a by placing a file at its destination. + await fs.writeFile(path.join(archiveRoot, 'auto-skill-a'), 'blocker'); + + const result = await runAutoSkillCurator(projectRoot, { now }); + + // auto-skill-a hits a collision (lstat succeeds → skippedCollisions). + expect(result.skippedCollisions).toContain('auto-skill-a'); + // auto-skill-b archives normally. + expect(result.archived).toContain('auto-skill-b'); + // State was persisted (lastRunAt is set). + const status = await getAutoSkillCuratorStatus(projectRoot, now); + expect(status.lastRunAt).toBeDefined(); + }); + + it('reports skippedErrors when rename fails transiently', async () => { + const now = new Date('2026-07-27T00:00:00.000Z'); + const old = new Date(now.getTime() - 100 * DAY_MS); + const manifest = await writeSkill('auto-skill-err', 'auto-skill', old); + await recordAutoSkillUsage( + projectRoot, + { name: 'err', level: 'project', filePath: manifest }, + old, + ); + + // Seed state so the curator proceeds past the seeding branch. + await runAutoSkillCurator(projectRoot, { + now: new Date(now.getTime() - 95 * DAY_MS), + }); + + // Ensure the archive root exists, then remove write permission from the + // skills root so rename (which needs write on the source parent) fails + // with EACCES while lstat on the destination still succeeds. + const skillsRoot = path.join(projectRoot, '.qwen', 'skills'); + const archiveRoot = path.join(projectRoot, '.qwen', 'archived-skills'); + await fs.mkdir(archiveRoot, { recursive: true }); + await fs.chmod(skillsRoot, 0o555); + + try { + const result = await runAutoSkillCurator(projectRoot, { now }); + expect(result.skippedErrors).toContain('auto-skill-err'); + expect(result.archived).not.toContain('auto-skill-err'); + const status = await getAutoSkillCuratorStatus(projectRoot, now); + expect(status.lastRunAt).toBeDefined(); + } finally { + await fs.chmod(skillsRoot, 0o755); + } + }); + + it('prunes records whose directory exists in neither root', async () => { + const now = new Date('2026-07-27T00:00:00.000Z'); + const old = new Date(now.getTime() - 100 * DAY_MS); + const manifest = await writeSkill('auto-skill-gone', 'auto-skill', old); + await recordAutoSkillUsage( + projectRoot, + { name: 'gone', level: 'project', filePath: manifest }, + old, + ); + + // Seed state with the skill present. + await runAutoSkillCurator(projectRoot, { + now: new Date(now.getTime() - 95 * DAY_MS), + }); + + // Delete the skill directory by hand. + await fs.rm(path.join(projectRoot, '.qwen', 'skills', 'auto-skill-gone'), { + recursive: true, + force: true, + }); + + // Run again — the record should be pruned. + await runAutoSkillCurator(projectRoot, { now }); + + const statePath = path.join(projectRoot, '.qwen', 'skill-curator.json'); + const state = JSON.parse(await fs.readFile(statePath, 'utf8')); + expect(state.skills['auto-skill-gone']).toBeUndefined(); + }); + + it('does not create a state file when no auto-skills exist', async () => { + const now = new Date('2026-07-27T00:00:00.000Z'); + await fs.mkdir(path.join(projectRoot, '.qwen', 'skills'), { + recursive: true, + }); + + const result = await maybeRunAutoSkillCurator(projectRoot, now); + + expect(result.status).toBe('seeded'); + if (result.status === 'seeded') { + expect(result.checked).toBe(0); + } + const statePath = path.join(projectRoot, '.qwen', 'skill-curator.json'); + await expect(fs.lstat(statePath)).rejects.toThrow(); + }); + + it('sanitizes directory names in pin error messages', async () => { + const evil = 'auto-skill-evil\u001b[31m'; + await expect(setAutoSkillPinned(projectRoot, evil, true)).rejects.toThrow( + JSON.stringify(evil), + ); + }); + + it('sanitizes directory names in restore error messages', async () => { + const evil = 'auto-skill-evil\u001b[31m'; + await expect(restoreArchivedAutoSkill(projectRoot, evil)).rejects.toThrow( + JSON.stringify(evil), + ); + }); +}); diff --git a/packages/core/src/skills/skill-curator.ts b/packages/core/src/skills/skill-curator.ts new file mode 100644 index 00000000000..be48785e68c --- /dev/null +++ b/packages/core/src/skills/skill-curator.ts @@ -0,0 +1,955 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs/promises'; +import { constants as fsConstants } from 'node:fs'; +import * as path from 'node:path'; +import lockfile from 'proper-lockfile'; +import { QWEN_DIR } from '../config/storage.js'; +import { atomicWriteJSON } from '../utils/atomicFileWrite.js'; +import { parse as parseYaml } from '../utils/yaml-parser.js'; +import { + getArchivedSkillsRoot, + getProjectSkillsRoot, + SKILL_FILE_NAME, +} from './skill-paths.js'; +import type { SkillConfig } from './types.js'; +import { SKILL_NAME_PATTERN, validateSkillName } from './types.js'; + +export const AUTO_SKILL_CURATOR_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000; +export const AUTO_SKILL_STALE_AFTER_MS = 30 * 24 * 60 * 60 * 1000; +export const AUTO_SKILL_ARCHIVE_AFTER_MS = 90 * 24 * 60 * 60 * 1000; + +const AUTO_SKILL_PREFIX = 'auto-skill-'; +const CURATOR_STATE_VERSION = 1; +const CURATOR_STATE_FILE = 'skill-curator.json'; +const CURATOR_LOCK_FILE = 'skill-curator.lock'; +// A real state file is a few KB even with hundreds of skills. Cap the read so +// a crafted oversized regular file cannot drive an unbounded JSON parse. +const MAX_STATE_FILE_BYTES = 1024 * 1024; +// A managed SKILL.md is a small frontmatter + body. Cap the read so a crafted +// or symlinked manifest (e.g. a git-mode-120000 link to /dev/zero) cannot drive +// an unbounded read; the ceiling is well above any real manifest. +const MAX_MANIFEST_BYTES = 4 * 1024 * 1024; + +// O_NOFOLLOW refuses a symlink atomically at open (ELOOP) — closing the +// lstat→read TOCTOU window a bare readFile leaves open — and O_NONBLOCK keeps a +// FIFO from hanging the open (the fstat guard then rejects it as non-regular). +// Both constants are omitted on platforms that lack them (Windows), where they +// reduce to plain O_RDONLY. Resolved lazily rather than at module load so the +// node:fs constants are not touched at import time — tests that mock node:fs +// without a `constants` export install a proxy that throws on access. +function noFollowReadFlags(): number { + return ( + (fsConstants.O_RDONLY ?? 0) | + (fsConstants.O_NOFOLLOW ?? 0) | + (fsConstants.O_NONBLOCK ?? 0) + ); +} + +/** + * Read an entire regular file with O_NOFOLLOW and a size bound taken from an + * fstat on the open descriptor. Unlike an `lstat` guard followed by a separate + * `readFile`, this cannot be raced: a symlink swapped in after any earlier stat + * is refused atomically at open, and the size is re-checked on the same fd that + * is read, so a target such as /dev/zero cannot drive an unbounded read. + */ +async function readRegularFileNoFollow( + filePath: string, + maxBytes: number, +): Promise<{ content: string; stat: import('node:fs').Stats }> { + const handle = await fs.open(filePath, noFollowReadFlags()); + try { + const stat = await handle.stat(); + if (!stat.isFile()) { + throw new Error( + `Auto-skill curator refuses unsafe path ${filePath} (not a regular file).`, + ); + } + if (stat.size > maxBytes) { + throw new Error(`Auto-skill curator file too large at ${filePath}.`); + } + const content = await handle.readFile('utf8'); + return { content, stat }; + } finally { + await handle.close().catch(() => {}); + } +} + +type AutoSkillState = 'active' | 'stale' | 'archived'; + +interface AutoSkillRecord { + skillName: string; + firstSeenAt: string; + lastActivityAt: string; + lastUsedAt?: string; + useCount: number; + state: AutoSkillState; + pinned: boolean; + archivedAt?: string; +} + +interface AutoSkillCuratorState { + version: 1; + lastRunAt?: string; + skills: Record; +} + +interface ManagedAutoSkill { + directoryName: string; + skillName: string; + directoryPath: string; + manifestPath: string; + modifiedAt: string; +} + +export interface AutoSkillCuratorEntry { + directoryName: string; + skillName: string; + state: AutoSkillState; + lastActivityAt: string; + useCount: number; + pinned: boolean; +} + +export interface AutoSkillCuratorStatus { + lastRunAt?: string; + active: AutoSkillCuratorEntry[]; + stale: AutoSkillCuratorEntry[]; + archived: AutoSkillCuratorEntry[]; +} + +export interface AutoSkillCuratorRunResult { + dryRun: boolean; + checked: number; + seeded: string[]; + markedStale: string[]; + reactivated: string[]; + archived: string[]; + skippedCollisions: string[]; + skippedErrors: string[]; +} + +export type AutoSkillCuratorAutomaticResult = + | { status: 'seeded'; checked: number } + | { status: 'not_due' } + | { status: 'ran'; result: AutoSkillCuratorRunResult }; + +interface CuratorPaths { + qwenRoot: string; + skillsRoot: string; + archiveRoot: string; + statePath: string; + lockPath: string; +} + +const LOCK_OPTIONS: lockfile.LockOptions = { + realpath: false, + retries: { + retries: 8, + minTimeout: 25, + maxTimeout: 500, + factor: 2, + randomize: true, + }, + stale: 10_000, +}; + +function getCuratorPaths(projectRoot: string): CuratorPaths { + const qwenRoot = path.join(projectRoot, QWEN_DIR); + return { + qwenRoot, + skillsRoot: getProjectSkillsRoot(projectRoot), + archiveRoot: getArchivedSkillsRoot(projectRoot), + statePath: path.join(qwenRoot, CURATOR_STATE_FILE), + lockPath: path.join(qwenRoot, CURATOR_LOCK_FILE), + }; +} + +function emptyState(): AutoSkillCuratorState { + return { version: CURATOR_STATE_VERSION, skills: Object.create(null) }; +} + +function isMissing(error: unknown): boolean { + return ( + error != null && + typeof error === 'object' && + (error as NodeJS.ErrnoException).code === 'ENOENT' + ); +} + +function parseTimestamp(value: unknown): number | undefined { + if (typeof value !== 'string') return undefined; + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : undefined; +} + +function requireTimestamp(value: unknown, field: string): string { + if (parseTimestamp(value) === undefined) { + throw new Error(`Invalid auto-skill curator state: ${field} is invalid.`); + } + return value as string; +} + +function parseState(raw: unknown, statePath: string): AutoSkillCuratorState { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + throw new Error(`Invalid auto-skill curator state at ${statePath}.`); + } + const input = raw as Record; + if (input['version'] !== CURATOR_STATE_VERSION) { + throw new Error( + `Unsupported auto-skill curator state version at ${statePath}.`, + ); + } + const rawSkills = input['skills']; + if (!rawSkills || typeof rawSkills !== 'object' || Array.isArray(rawSkills)) { + throw new Error(`Invalid auto-skill curator state at ${statePath}.`); + } + + const skills = Object.create(null) as Record; + for (const [directoryName, value] of Object.entries(rawSkills)) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error( + `Invalid auto-skill curator record for ${directoryName}.`, + ); + } + const record = value as Record; + const state = record['state']; + if (state !== 'active' && state !== 'stale' && state !== 'archived') { + throw new Error( + `Invalid auto-skill curator record for ${directoryName}.`, + ); + } + const skillName = record['skillName']; + const useCount = record['useCount']; + const pinned = record['pinned']; + if ( + typeof skillName !== 'string' || + !Number.isInteger(useCount) || + (useCount as number) < 0 || + (pinned !== undefined && typeof pinned !== 'boolean') + ) { + throw new Error( + `Invalid auto-skill curator record for ${directoryName}.`, + ); + } + skills[directoryName] = { + skillName, + firstSeenAt: requireTimestamp( + record['firstSeenAt'], + `${directoryName}.firstSeenAt`, + ), + lastActivityAt: requireTimestamp( + record['lastActivityAt'], + `${directoryName}.lastActivityAt`, + ), + useCount: useCount as number, + state, + pinned: pinned ?? false, + ...(record['lastUsedAt'] !== undefined + ? { + lastUsedAt: requireTimestamp( + record['lastUsedAt'], + `${directoryName}.lastUsedAt`, + ), + } + : {}), + ...(record['archivedAt'] !== undefined + ? { + archivedAt: requireTimestamp( + record['archivedAt'], + `${directoryName}.archivedAt`, + ), + } + : {}), + }; + } + + return { + version: CURATOR_STATE_VERSION, + skills, + ...(input['lastRunAt'] !== undefined + ? { lastRunAt: requireTimestamp(input['lastRunAt'], 'lastRunAt') } + : {}), + }; +} + +async function readState(statePath: string): Promise { + let stat: import('node:fs').Stats; + try { + stat = await fs.lstat(statePath); + } catch (error) { + if (isMissing(error)) return emptyState(); + throw error; + } + // Mirror the noFollow/lstat guards every write already uses: a state file + // that is a symlink (committable via git mode 120000, e.g. pointing at + // /dev/zero or a path outside .qwen/) or a FIFO would otherwise be followed, + // driving an unbounded read (OOM) or blocking the CLI boot indefinitely. + if (!stat.isFile() || stat.isSymbolicLink()) { + throw new Error(`Auto-skill curator refuses unsafe path ${statePath}.`); + } + if (stat.size > MAX_STATE_FILE_BYTES) { + throw new Error(`Invalid auto-skill curator state at ${statePath}.`); + } + let content: string; + try { + // Read with O_NOFOLLOW rather than a bare readFile: the lstat above only + // proves the path was safe at that instant, so a symlink (or a growing + // file) swapped in before the read would otherwise be followed. The + // O_NOFOLLOW open refuses that atomically and the fstat re-bounds the size. + ({ content } = await readRegularFileNoFollow( + statePath, + MAX_STATE_FILE_BYTES, + )); + } catch (error) { + if (isMissing(error)) return emptyState(); + // The file passed the lstat guard, but the read can still fail because it + // raced into a symlink / non-regular file, grew past the cap, or hit a + // transient I/O error such as EMFILE, EACCES, or EIO. Fail closed either + // way, while retaining the original failure for diagnosis. + throw new Error(`Auto-skill curator refuses unsafe path ${statePath}.`, { + cause: error, + }); + } + try { + return parseState(JSON.parse(content), statePath); + } catch (error) { + if (error instanceof SyntaxError) { + throw new Error(`Invalid auto-skill curator state at ${statePath}.`); + } + throw error; + } +} + +function parseAutoSkillName(content: string): string | undefined { + const match = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/.exec( + content, + ); + if (!match) return undefined; + const frontmatter = parseYaml(match[1]); + if (frontmatter['source'] !== 'auto-skill') return undefined; + const name = frontmatter['name']; + if (typeof name !== 'string') return undefined; + try { + validateSkillName(name); + } catch { + return undefined; + } + return name; +} + +function isManagedDirectoryName(directoryName: string): boolean { + return ( + directoryName.startsWith(AUTO_SKILL_PREFIX) && + path.basename(directoryName) === directoryName && + // Restrict to the skill-name charset (letters, digits, _ : . -). A managed + // directory is always `auto-skill-` where `` passes + // validateSkillName and the prefix chars are within the same set, so this + // never rejects a legitimately generated directory. It does reject names + // carrying ANSI/control bytes, whose `directoryName` is later printed + // verbatim by the non-interactive `/curator` output (which, unlike the TUI, + // does not run escapeAnsiCtrlCodes) — closing a terminal control-sequence + // injection via a crafted directory committed to a cloned repo. + SKILL_NAME_PATTERN.test(directoryName) + ); +} + +async function readManagedSkill( + root: string, + directoryName: string, +): Promise { + if (!isManagedDirectoryName(directoryName)) return undefined; + const directoryPath = path.join(root, directoryName); + const manifestPath = path.join(directoryPath, SKILL_FILE_NAME); + try { + const directoryStat = await fs.lstat(directoryPath); + if (directoryStat.isSymbolicLink() || !directoryStat.isDirectory()) { + return undefined; + } + // Read the manifest with O_NOFOLLOW instead of the previous + // `Promise.all([lstat, readFile])`, where the readFile ran concurrently + // with the lstat guard: a symlinked SKILL.md (git mode 120000, survives a + // clone) pointing at /dev/zero would begin an unbounded read before the + // lstat could reject it. The O_NOFOLLOW open refuses the symlink atomically + // and the fstat bounds the read, so a crafted manifest cannot OOM the scan. + const { content, stat: manifestStat } = await readRegularFileNoFollow( + manifestPath, + MAX_MANIFEST_BYTES, + ); + const skillName = parseAutoSkillName(content); + if (!skillName) return undefined; + return { + directoryName, + skillName, + directoryPath, + manifestPath, + modifiedAt: manifestStat.mtime.toISOString(), + }; + } catch { + return undefined; + } +} + +async function scanManagedSkills(root: string): Promise { + let entries: Array; + try { + const rootStat = await fs.lstat(root); + if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) { + throw new Error(`Auto-skill curator refuses unsafe path ${root}.`); + } + entries = await fs.readdir(root, { withFileTypes: true }); + } catch (error) { + if (isMissing(error)) return []; + throw error; + } + + const skills = await Promise.all( + entries + .filter( + (entry) => entry.isDirectory() && isManagedDirectoryName(entry.name), + ) + .map((entry) => readManagedSkill(root, entry.name)), + ); + return skills + .filter((skill): skill is ManagedAutoSkill => skill !== undefined) + .sort((a, b) => a.directoryName.localeCompare(b.directoryName)); +} + +async function ensureSafeQwenRoot(paths: CuratorPaths): Promise { + await fs.mkdir(paths.qwenRoot, { recursive: true }); + const stat = await fs.lstat(paths.qwenRoot); + if (!stat.isDirectory() || stat.isSymbolicLink()) { + throw new Error( + `Auto-skill curator refuses unsafe path ${paths.qwenRoot}.`, + ); + } +} + +async function ensureSafeDirectory(directory: string): Promise { + await fs.mkdir(directory, { recursive: true }); + const stat = await fs.lstat(directory); + if (!stat.isDirectory() || stat.isSymbolicLink()) { + throw new Error(`Auto-skill curator refuses unsafe path ${directory}.`); + } +} + +async function withCuratorLock( + projectRoot: string, + operation: (paths: CuratorPaths) => Promise, +): Promise { + const paths = getCuratorPaths(projectRoot); + await ensureSafeQwenRoot(paths); + // proper-lockfile acquires `${lockPath}.lock` with an atomic mkdir. Do not + // create or validate `lockPath` itself: it is not the lock and would leave a + // permanent placeholder file behind after every curator operation. + const release = await lockfile.lock(paths.lockPath, LOCK_OPTIONS); + try { + return await operation(paths); + } finally { + try { + await release(); + } catch { + // The state mutation already completed; stale-lock cleanup is non-fatal. + } + } +} + +function recordForSkill( + state: AutoSkillCuratorState, + skill: ManagedAutoSkill, + firstSeenAt: string = skill.modifiedAt, +): AutoSkillRecord { + const existing = state.skills[skill.directoryName]; + if (existing) { + existing.skillName = skill.skillName; + return existing; + } + const record: AutoSkillRecord = { + skillName: skill.skillName, + firstSeenAt, + lastActivityAt: firstSeenAt, + useCount: 0, + state: 'active', + pinned: false, + }; + state.skills[skill.directoryName] = record; + return record; +} + +function lastActivityMs( + skill: ManagedAutoSkill, + record: AutoSkillRecord, + nowMs: number, +): number { + const mtimeMs = parseTimestamp(skill.modifiedAt) ?? 0; + return Math.max( + mtimeMs <= nowMs ? mtimeMs : 0, + parseTimestamp(record.firstSeenAt) ?? 0, + parseTimestamp(record.lastActivityAt) ?? 0, + parseTimestamp(record.lastUsedAt) ?? 0, + ); +} + +function entryFor( + skill: ManagedAutoSkill, + record: AutoSkillRecord, + state: AutoSkillState, + nowMs: number, +): AutoSkillCuratorEntry { + return { + directoryName: skill.directoryName, + skillName: skill.skillName, + state, + lastActivityAt: new Date( + lastActivityMs(skill, record, nowMs), + ).toISOString(), + useCount: record.useCount, + pinned: record.pinned, + }; +} + +async function rollbackMoves( + moved: Array<{ source: string; destination: string }>, +): Promise { + const errors: string[] = []; + for (const move of [...moved].reverse()) { + try { + await fs.rename(move.destination, move.source); + } catch (error) { + errors.push(error instanceof Error ? error.message : String(error)); + } + } + return errors; +} + +async function runLocked( + paths: CuratorPaths, + state: AutoSkillCuratorState, + now: Date, +): Promise { + const skills = await scanManagedSkills(paths.skillsRoot); + const nowMs = now.getTime(); + const nowIso = now.toISOString(); + const result: AutoSkillCuratorRunResult = { + dryRun: false, + checked: skills.length, + seeded: [], + markedStale: [], + reactivated: [], + archived: [], + skippedCollisions: [], + skippedErrors: [], + }; + const moved: Array<{ source: string; destination: string }> = []; + + try { + for (const scanned of skills) { + const existing = state.skills[scanned.directoryName]; + const record = recordForSkill(state, scanned, nowIso); + if (!existing) { + result.seeded.push(scanned.directoryName); + continue; + } + if (record.pinned) continue; + const inactivityMs = nowMs - lastActivityMs(scanned, record, nowMs); + if (inactivityMs >= AUTO_SKILL_ARCHIVE_AFTER_MS) { + const current = await readManagedSkill( + paths.skillsRoot, + scanned.directoryName, + ); + if (!current) continue; + const currentInactivityMs = + nowMs - lastActivityMs(current, record, nowMs); + if (currentInactivityMs < AUTO_SKILL_ARCHIVE_AFTER_MS) { + if (currentInactivityMs >= AUTO_SKILL_STALE_AFTER_MS) { + if (record.state !== 'stale') { + record.state = 'stale'; + delete record.archivedAt; + result.markedStale.push(scanned.directoryName); + } + } else if (record.state !== 'active') { + record.state = 'active'; + delete record.archivedAt; + result.reactivated.push(scanned.directoryName); + } + continue; + } + await ensureSafeDirectory(paths.archiveRoot); + const destination = path.join(paths.archiveRoot, scanned.directoryName); + try { + await fs.lstat(destination); + result.skippedCollisions.push(scanned.directoryName); + continue; + } catch (error) { + if (!isMissing(error)) throw error; + } + try { + await fs.rename(current.directoryPath, destination); + } catch { + result.skippedErrors.push(scanned.directoryName); + continue; + } + moved.push({ source: current.directoryPath, destination }); + record.state = 'archived'; + record.archivedAt = nowIso; + result.archived.push(scanned.directoryName); + } else if (inactivityMs >= AUTO_SKILL_STALE_AFTER_MS) { + if (record.state !== 'stale') { + record.state = 'stale'; + delete record.archivedAt; + result.markedStale.push(scanned.directoryName); + } + } else if (record.state !== 'active') { + record.state = 'active'; + delete record.archivedAt; + result.reactivated.push(scanned.directoryName); + } + } + // Prune records whose directory exists in neither root so the state + // file does not grow without bound in long-lived projects. Check raw + // directory presence (not eligibility) so an ineligible-but-present + // directory keeps its record. + const listDirs = async (root: string): Promise> => { + try { + const entries = await fs.readdir(root, { withFileTypes: true }); + return new Set( + entries.filter((e) => e.isDirectory()).map((e) => e.name), + ); + } catch { + return new Set(); + } + }; + const [liveDirs, archiveDirs] = await Promise.all([ + listDirs(paths.skillsRoot), + listDirs(paths.archiveRoot), + ]); + for (const dirName of Object.keys(state.skills)) { + if (!liveDirs.has(dirName) && !archiveDirs.has(dirName)) { + delete state.skills[dirName]; + } + } + state.lastRunAt = nowIso; + await atomicWriteJSON(paths.statePath, state, { + mode: 0o600, + noFollow: true, + }); + return result; + } catch (error) { + const rollbackErrors = await rollbackMoves(moved); + if (rollbackErrors.length > 0) { + throw new Error(`Rollback failed: ${rollbackErrors.join('; ')}`, { + cause: error, + }); + } + throw error; + } +} + +async function previewRun( + projectRoot: string, + now: Date, +): Promise { + const paths = getCuratorPaths(projectRoot); + const [state, skills] = await Promise.all([ + readState(paths.statePath), + scanManagedSkills(paths.skillsRoot), + ]); + const result: AutoSkillCuratorRunResult = { + dryRun: true, + checked: skills.length, + seeded: [], + markedStale: [], + reactivated: [], + archived: [], + skippedCollisions: [], + skippedErrors: [], + }; + const nowMs = now.getTime(); + for (const skill of skills) { + const existing = state.skills[skill.directoryName]; + if (!existing) { + result.seeded.push(skill.directoryName); + continue; + } + const record = recordForSkill(state, skill); + if (record.pinned) continue; + const inactivityMs = nowMs - lastActivityMs(skill, record, nowMs); + if (inactivityMs >= AUTO_SKILL_ARCHIVE_AFTER_MS) { + try { + await fs.lstat(path.join(paths.archiveRoot, skill.directoryName)); + result.skippedCollisions.push(skill.directoryName); + } catch (error) { + if (!isMissing(error)) throw error; + result.archived.push(skill.directoryName); + } + } else if ( + inactivityMs >= AUTO_SKILL_STALE_AFTER_MS && + record.state !== 'stale' + ) { + result.markedStale.push(skill.directoryName); + } else if ( + inactivityMs < AUTO_SKILL_STALE_AFTER_MS && + record.state !== 'active' + ) { + result.reactivated.push(skill.directoryName); + } + } + return result; +} + +export async function runAutoSkillCurator( + projectRoot: string, + options: { dryRun?: boolean; now?: Date } = {}, +): Promise { + const now = options.now ?? new Date(); + if (options.dryRun) return previewRun(projectRoot, now); + return withCuratorLock(projectRoot, async (paths) => + runLocked(paths, await readState(paths.statePath), now), + ); +} + +export async function maybeRunAutoSkillCurator( + projectRoot: string, + now: Date = new Date(), +): Promise { + const paths = getCuratorPaths(projectRoot); + // Fast path: readState is safe unlocked (atomic-rename writes prevent torn + // reads), so most boots return not_due without paying for the lock. + const unlocked = await readState(paths.statePath); + if (unlocked.lastRunAt) { + const lastRunMs = parseTimestamp(unlocked.lastRunAt)!; + if (now.getTime() - lastRunMs < AUTO_SKILL_CURATOR_INTERVAL_MS) { + return { status: 'not_due' }; + } + } + return withCuratorLock(projectRoot, async (lockedPaths) => { + const state = await readState(lockedPaths.statePath); + if (!state.lastRunAt) { + const nowIso = now.toISOString(); + const skills = await scanManagedSkills(lockedPaths.skillsRoot); + if (skills.length === 0 && Object.keys(state.skills).length === 0) { + return { status: 'seeded', checked: 0 }; + } + for (const skill of skills) { + const existing = state.skills[skill.directoryName]; + state.skills[skill.directoryName] = { + skillName: skill.skillName, + // Preserve an existing baseline the way useCount/pinned/lastUsedAt are + // preserved below: if recordAutoSkillUsage already created a record + // before this first curator run, overwriting firstSeenAt/ + // lastActivityAt with `now` would reset the inactivity clock and delay + // the stale transition. A brand-new skill still gets a fresh `now` + // baseline (never the old manifest mtime), keeping first-sight grace. + firstSeenAt: existing?.firstSeenAt ?? nowIso, + lastActivityAt: existing?.lastActivityAt ?? nowIso, + useCount: existing?.useCount ?? 0, + state: 'active', + pinned: existing?.pinned ?? false, + ...(existing?.lastUsedAt ? { lastUsedAt: existing.lastUsedAt } : {}), + }; + } + state.lastRunAt = nowIso; + await atomicWriteJSON(lockedPaths.statePath, state, { + mode: 0o600, + noFollow: true, + }); + return { status: 'seeded', checked: skills.length }; + } + // Re-check under lock: another process may have run while we waited. + const lastRunMs = parseTimestamp(state.lastRunAt)!; + if (now.getTime() - lastRunMs < AUTO_SKILL_CURATOR_INTERVAL_MS) { + return { status: 'not_due' }; + } + return { + status: 'ran', + result: await runLocked(lockedPaths, state, now), + }; + }); +} + +export async function recordAutoSkillUsage( + projectRoot: string, + skill: Pick, + now: Date = new Date(), +): Promise { + if (skill.level !== 'project') return false; + const paths = getCuratorPaths(projectRoot); + const resolvedManifestPath = path.resolve(skill.filePath); + const directoryPath = path.dirname(resolvedManifestPath); + if (path.dirname(directoryPath) !== path.resolve(paths.skillsRoot)) { + return false; + } + const directoryName = path.basename(directoryPath); + const candidate = await readManagedSkill(paths.skillsRoot, directoryName); + if (!candidate || candidate.manifestPath !== resolvedManifestPath) { + return false; + } + return withCuratorLock(projectRoot, async (lockedPaths) => { + const managed = await readManagedSkill( + lockedPaths.skillsRoot, + directoryName, + ); + if (!managed || managed.manifestPath !== resolvedManifestPath) { + return false; + } + const state = await readState(lockedPaths.statePath); + const nowIso = now.toISOString(); + const record = recordForSkill(state, managed, nowIso); + record.lastActivityAt = nowIso; + record.lastUsedAt = nowIso; + record.useCount += 1; + record.state = 'active'; + delete record.archivedAt; + await atomicWriteJSON(lockedPaths.statePath, state, { + mode: 0o600, + noFollow: true, + }); + return true; + }); +} + +export async function setAutoSkillPinned( + projectRoot: string, + directoryName: string, + pinned: boolean, + now: Date = new Date(), +): Promise { + if (!isManagedDirectoryName(directoryName)) { + throw new Error( + `Managed auto-skill not found: ${JSON.stringify(directoryName)}.`, + ); + } + await withCuratorLock(projectRoot, async (paths) => { + const managed = await readManagedSkill(paths.skillsRoot, directoryName); + if (!managed) { + throw new Error(`Managed auto-skill not found: ${directoryName}.`); + } + const state = await readState(paths.statePath); + const record = recordForSkill(state, managed, now.toISOString()); + record.pinned = pinned; + await atomicWriteJSON(paths.statePath, state, { + mode: 0o600, + noFollow: true, + }); + }); +} + +export async function getAutoSkillCuratorStatus( + projectRoot: string, + now: Date = new Date(), +): Promise { + const paths = getCuratorPaths(projectRoot); + const [state, liveSkills, archivedSkills] = await Promise.all([ + readState(paths.statePath), + scanManagedSkills(paths.skillsRoot), + scanManagedSkills(paths.archiveRoot), + ]); + const status: AutoSkillCuratorStatus = { + lastRunAt: state.lastRunAt, + active: [], + stale: [], + archived: [], + }; + const nowMs = now.getTime(); + for (const skill of liveSkills) { + const record = recordForSkill(state, skill, now.toISOString()); + const inactivityMs = nowMs - lastActivityMs(skill, record, nowMs); + const effectiveState: AutoSkillState = record.pinned + ? record.state === 'stale' + ? 'stale' + : 'active' + : inactivityMs >= AUTO_SKILL_STALE_AFTER_MS + ? 'stale' + : 'active'; + status[effectiveState].push(entryFor(skill, record, effectiveState, nowMs)); + } + const liveNames = new Set(liveSkills.map((s) => s.directoryName)); + for (const skill of archivedSkills) { + if (liveNames.has(skill.directoryName)) continue; + const record = recordForSkill(state, skill); + status.archived.push(entryFor(skill, record, 'archived', nowMs)); + } + return status; +} + +export async function restoreArchivedAutoSkill( + projectRoot: string, + directoryName: string, + now: Date = new Date(), +): Promise { + await withCuratorLock(projectRoot, async (paths) => { + if (!isManagedDirectoryName(directoryName)) { + throw new Error( + `Archived auto-skill not found: ${JSON.stringify(directoryName)}.`, + ); + } + const archiveRootStat = await fs.lstat(paths.archiveRoot).catch((error) => { + if (isMissing(error)) return undefined; + throw error; + }); + if (!archiveRootStat) { + throw new Error(`Archived auto-skill not found: ${directoryName}.`); + } + if (!archiveRootStat.isDirectory() || archiveRootStat.isSymbolicLink()) { + throw new Error( + `Auto-skill curator refuses unsafe path ${paths.archiveRoot}.`, + ); + } + const archived = await readManagedSkill(paths.archiveRoot, directoryName); + if (!archived) { + // readManagedSkill returns undefined both when the directory is absent + // and when it exists but is not an eligible archived skill (e.g. a + // hand-edited manifest that lost its frontmatter). Distinguish the two so + // a plainly-present directory is not reported as missing. + let directoryExists = true; + try { + await fs.lstat(path.join(paths.archiveRoot, directoryName)); + } catch (error) { + if (!isMissing(error)) throw error; + directoryExists = false; + } + throw new Error( + directoryExists + ? `Archived auto-skill ${directoryName} is not an eligible managed skill.` + : `Archived auto-skill not found: ${directoryName}.`, + ); + } + const destination = path.join(paths.skillsRoot, directoryName); + try { + await fs.lstat(destination); + throw new Error( + `Cannot restore ${directoryName}: an active directory already exists.`, + ); + } catch (error) { + if (!isMissing(error)) throw error; + } + await ensureSafeDirectory(paths.skillsRoot); + const state = await readState(paths.statePath); + const record = recordForSkill(state, archived); + record.state = 'active'; + record.lastActivityAt = now.toISOString(); + delete record.archivedAt; + + await fs.rename(archived.directoryPath, destination); + try { + await atomicWriteJSON(paths.statePath, state, { + mode: 0o600, + noFollow: true, + }); + } catch (error) { + try { + await fs.rename(destination, archived.directoryPath); + } catch (rollbackError) { + throw new Error( + `Rollback failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`, + { cause: error }, + ); + } + throw error; + } + }); +} diff --git a/packages/core/src/skills/skill-paths.ts b/packages/core/src/skills/skill-paths.ts index 8228bdadc61..65c16b960be 100644 --- a/packages/core/src/skills/skill-paths.ts +++ b/packages/core/src/skills/skill-paths.ts @@ -8,12 +8,20 @@ import * as path from 'node:path'; import * as fs from 'node:fs/promises'; export const PROJECT_SKILLS_RELATIVE_DIR = path.join('.qwen', 'skills'); +export const ARCHIVED_SKILLS_RELATIVE_DIR = path.join( + '.qwen', + 'archived-skills', +); export const SKILL_FILE_NAME = 'SKILL.md'; export function getProjectSkillsRoot(projectRoot: string): string { return path.join(projectRoot, PROJECT_SKILLS_RELATIVE_DIR); } +export function getArchivedSkillsRoot(projectRoot: string): string { + return path.join(projectRoot, ARCHIVED_SKILLS_RELATIVE_DIR); +} + export const PENDING_SKILLS_RELATIVE_DIR = path.join('.qwen', 'pending-skills'); /** diff --git a/packages/core/src/tools/skill.test.ts b/packages/core/src/tools/skill.test.ts index d2cc2f12837..420d9326bb5 100644 --- a/packages/core/src/tools/skill.test.ts +++ b/packages/core/src/tools/skill.test.ts @@ -19,6 +19,7 @@ import { clearCollectedSkillEntriesCache, renderAvailableSkillsBlock, } from './skill-utils.js'; +import { recordAutoSkillUsage } from '../skills/skill-curator.js'; // Type for accessing protected methods in tests type SkillToolWithProtectedMethods = SkillTool & { @@ -37,6 +38,9 @@ type SkillToolWithProtectedMethods = SkillTool & { // Mock dependencies vi.mock('../skills/skill-manager.js'); +vi.mock('../skills/skill-curator.js', () => ({ + recordAutoSkillUsage: vi.fn().mockResolvedValue(false), +})); vi.mock('../telemetry/index.js', () => ({ logSkillLaunch: vi.fn(), recordSkillInvocation: vi.fn(), @@ -89,6 +93,7 @@ describe('SkillTool', () => { // Create mock config config = { getProjectRoot: vi.fn().mockReturnValue('/test/project'), + getAutoSkillEnabled: vi.fn().mockReturnValue(true), getSessionId: vi.fn().mockReturnValue('test-session-id'), getSkillManager: vi.fn(), getGeminiClient: vi.fn().mockReturnValue(undefined), @@ -620,6 +625,46 @@ describe('SkillTool', () => { skillName: 'code-review', success: true, }); + expect(recordAutoSkillUsage).toHaveBeenCalledWith( + '/test/project', + mockRuntimeConfig, + ); + }); + + it('records usage while Auto Skill generation is disabled', async () => { + vi.mocked(config.getAutoSkillEnabled).mockReturnValue(false); + + const invocation = ( + skillTool as SkillToolWithProtectedMethods + ).createInvocation({ skill: 'code-review' }); + await invocation.execute(); + + expect(recordAutoSkillUsage).toHaveBeenCalledWith( + '/test/project', + mockRuntimeConfig, + ); + }); + + it('keeps skill execution successful when usage recording fails', async () => { + vi.mocked(recordAutoSkillUsage).mockRejectedValueOnce( + new Error('lock busy'), + ); + + const invocation = ( + skillTool as SkillToolWithProtectedMethods + ).createInvocation({ skill: 'code-review' }); + const result = await invocation.execute(); + + expect(partToString(result.llmContent)).toContain( + 'Review code for quality and best practices.', + ); + expect(result.returnDisplay).toBe( + 'Specialized skill for reviewing code quality', + ); + expect(recordAutoSkillUsage).toHaveBeenCalledWith( + '/test/project', + mockRuntimeConfig, + ); }); it('should include allowedTools in result when present', async () => { @@ -718,6 +763,7 @@ describe('SkillTool', () => { skillName: 'code-review', success: false, }); + expect(recordAutoSkillUsage).not.toHaveBeenCalled(); }); it("L3 default is 'ask' so AUTO mode routes through the classifier", async () => { @@ -1032,6 +1078,32 @@ describe('SkillTool', () => { }), ); }); + + it('records auto-skill usage on re-invocation of an already-loaded skill', async () => { + vi.mocked(mockSkillManager.loadSkillForRuntime).mockResolvedValue( + mockRuntimeConfig, + ); + + const inv1 = ( + skillTool as SkillToolWithProtectedMethods + ).createInvocation({ skill: 'code-review' }); + await inv1.execute(); + + vi.mocked(recordAutoSkillUsage).mockClear(); + + const inv2 = ( + skillTool as SkillToolWithProtectedMethods + ).createInvocation({ skill: 'code-review' }); + const result2 = await inv2.execute(); + + expect(partToString(result2.llmContent)).toBe( + 'Skill "code-review" is already loaded in context.', + ); + expect(recordAutoSkillUsage).toHaveBeenCalledWith( + '/test/project', + mockRuntimeConfig, + ); + }); }); describe('modelInvocableCommands integration', () => { diff --git a/packages/core/src/tools/skill.ts b/packages/core/src/tools/skill.ts index 1da30b705ff..58d426a7828 100644 --- a/packages/core/src/tools/skill.ts +++ b/packages/core/src/tools/skill.ts @@ -22,6 +22,7 @@ import { import path from 'path'; import { createDebugLogger } from '../utils/debugLogger.js'; import { registerSkillHooks } from '../hooks/registerSkillHooks.js'; +import { recordAutoSkillUsage } from '../skills/skill-curator.js'; const debugLogger = createDebugLogger('SKILL'); @@ -340,6 +341,18 @@ class SkillToolInvocation extends BaseToolInvocation { return 'ask'; } + private async recordAutoSkillUsageBestEffort( + skill: SkillConfig, + ): Promise { + try { + await recordAutoSkillUsage(this.config.getProjectRoot(), skill); + } catch (error) { + debugLogger.warn( + `Failed to record auto-skill usage: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + async execute( _signal?: AbortSignal, _updateOutput?: (output: ToolResultDisplay) => void, @@ -495,6 +508,7 @@ class SkillToolInvocation extends BaseToolInvocation { // onSkillLoaded, which adds the name to the loaded set. if (this.isSkillLoaded(this.params.skill)) { this.onSkillLoaded(this.params.skill); + void this.recordAutoSkillUsageBestEffort(skill); const msg = `Skill "${this.params.skill}" is already loaded in context.`; return { llmContent: msg, @@ -548,6 +562,7 @@ class SkillToolInvocation extends BaseToolInvocation { const baseDir = path.dirname(skill.filePath); const llmContent = buildSkillLlmContent(baseDir, skill.body); + void this.recordAutoSkillUsageBestEffort(skill); recordSkillInvocation(this.config, { skillName: this.params.skill, success: true,