From a6ee31f55aad08cc51ba348db2febacd541eec01 Mon Sep 17 00:00:00 2001 From: shannonsands Date: Fri, 11 Sep 2026 19:04:06 +1000 Subject: [PATCH] feat(wisdom): add Hermes Collective Wisdom Agent V1 (#94266) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(wisdom): add trusted publish and install foundation * feat(wisdom): add private contribution loop * feat(wisdom): add managed consumption workflows * fix(wisdom): close cross-repository safety gaps * fix(wisdom): align local package and lifecycle policy * fix(wisdom): require explicit profile setup * docs(wisdom): repin reconciled gateway head * fix(wisdom): fence content downloads and approval receipts * docs(wisdom): record generation-fenced downloads * docs(wisdom): record unified delivery PR * fix(ci): stop passing invalid classifier inputs * docs(wisdom): remove internal requirements ledger * feat(wisdom): localize dashboard and desktop copy * feat(wisdom): complete local contribution and consumption UX * style(wisdom): satisfy desktop lint * chore(wisdom): refresh requirements pin * test(dashboard): allow formatted profile copy * test(wisdom): stabilize desktop interaction coverage * fix(wisdom): surface dashboard action failures * fix(wisdom): add repeatable Portal demo login * feat(wisdom): add actionable skill notifications * feat(wisdom): add notification install and update actions * fix(wisdom): make Telegram skill alerts actionable * fix(wisdom): always refresh demo Agent login * feat(wisdom): embed Telegram notification actions * fix(wisdom): preserve Telegram notifications after actions * fix(wisdom): keep Telegram notification cards readable * feat(wisdom): add Telegram candidate approval flow * feat(wisdom): explain Telegram qualification reasons * fix(wisdom): reconcile cross-surface candidate actions * feat(telegram): add Collective Wisdom management command * chore(wisdom): refresh Gateway contract pin * chore(wisdom): advance Gateway contract pin * feat(wisdom): align command UX across clients * feat(slack): add Collective Wisdom management parity * feat(wisdom): add security and professionalism reviews * feat(wisdom): add first-time qualification guidance * feat(wisdom): simplify qualification sharing choices * feat(skills): add optional editorial metadata * feat(wisdom): enrich legacy skill presentation * fix(wisdom): harden review and update boundaries * fix(wisdom): emit canonical review timestamps * fix(wisdom): align with merged gateway and main * wisdom: add agent-led sharing core (policy, evidence, schemas, templates, delivery, weekly job, share/install flows) - hermes_wisdom/agent_led/: policy resolution (server > local > defaults), 7-day evidence builder that excludes bundled/hub/managed skills and dismissed/handled/recently-suggested content hashes, strict pydantic schemas for agent output with repair-or-reject, fixed copy templates (Share / Teammate / Published / Update / Mute), idempotent retried delivery ledger with stale-action resolution, weekly review job, resumable Share and Install flows. - prompts/: candidate review, recipient recommendation, share packaging. - tests/wisdom/test_agent_led.py: 30 tests. * wisdom: agent-led renderers and button action dispatcher - render.py: Telegram HTML, Slack blocks, Desktop payload; editorial name is the emphasized line, product label stays separate. - actions.py: resolve opaque wa:: targets via the delivery ledger; Not now -> dismissal, Mute -> fixed options, Share -> resumable packaging flow, Install/Update -> plan command. Never publishes/installs. * wisdom: CLI verbs, agent_led config default, conversational catalog skill - hermes wisdom browse/review-week/act/share/dismiss/mute (all --json). - wisdom.agent_led config block, default enabled. - SKILL.md rewritten so natural-language catalog questions map to the CLI verbs, share/install flows and fixed notification templates. * wisdom: wire agent-led weekly review into gateway tick and Telegram buttons - gateway housekeeping tick calls maybe_run_weekly_review with a home channel sender when a Telegram adapter is available. - Telegram: wa: callbacks resolved through the ledger (stale-safe), mute duration keyboard, send_wisdom_agent_recommendation rich card + fallback. * fix(wisdom): integrate local mediation and harden model and setup boundaries * fix(wisdom): honor authoritative recommendation policy and defer on failure * fix(wisdom): synchronize opaque suppression and recheck delivery preferences * feat(wisdom): route weekly selection through the session-owned assessment queue * fix(wisdom): prepare and submit the reviewed generated share package * feat(wisdom): separate native Share preparation from publication consent * feat(wisdom): sync native mute choices through a leased preference outbox * feat(wisdom): bind native mute controls to durable preference choices * feat(wisdom): add scoped desktop and dashboard notification settings * fix(wisdom): revalidate feed recommendations before assessment and delivery * fix(wisdom): persist validated delivery receipts before completing notices * feat(wisdom): add private notification claim and receipt client * Persist Wisdom send reservations and recover delivery acknowledgements * Route legacy Wisdom controls through current native review * Add typed private Wisdom operation outcome client * fix(wisdom): make agent-led advice usable in the local demo * fix(wisdom): keep requested consent outside proactive limits * fix(wisdom): distinguish unavailable assessments and preserve digest text * fix(wisdom): assess ongoing usefulness beyond the current task * fix(wisdom): restore immediate qualification sharing controls * fix(wisdom): separate qualification review from installation advice * fix(wisdom): collapse review checklists and simplify sharing copy * fix(wisdom): show compact sharing progress and publication receipts * fix(wisdom): require credential prefixes rather than matching skill names * fix(wisdom): finish package checks before presenting sharing consent * fix(wisdom): scan local skills before qualification cards * fix(wisdom): update moderation results on existing sharing cards * fix(wisdom): keep sharing review accessible from receipt cards * fix(wisdom): align mediated review cards and collapsible checks * fix(wisdom): clarify clean security summary wording * fix(wisdom): normalize consent plans and add explicit recheck * fix(wisdom): keep install and update receipts concise * fix(wisdom): collapse assessments and deduplicate operation cards * fix(wisdom): restore private Portal review from native cards * fix(wisdom): sync Portal publication to original consent card * fix(wisdom): show local skill version on sharing cards * fix(wisdom): skip agent recommendations for self-published versions * fix(wisdom): simplify candidate notices and local-edit recovery copy * feat(wisdom): submit locally reviewed packages with one confirmation * feat(wisdom): expose safe receipt and outcome sync recovery * wisdom: onboarding notice says detect and share, names the user's own skill Copy review from the product owner on the first and returning qualification notices (fixed delivery mode): - the feature blurb now says the org enabled detection *and sharing* - both notices say the detected skill is one the user created - both close with an exclamation mark Applied identically to hermes_wisdom.notice, the desktop and web i18n strings, and the tests that assert the sentences. * wisdom: one opener, no approval line, ask to share after the skill is shown Product owner review of the candidate card. - The Hermes written card now opens with the same sentence as the fixed card ("Your organisation has enabled Collective Wisdom, a feature designed to automatically detect and share useful skills across all team members.") instead of its own blurb, so there is one first time message. - "Nothing is shared without your approval." removed from Telegram, Slack and Desktop. The buttons already make the permission explicit. - "Would you like to share?" no longer appears before the skill is named. It is now the last line, after the skill name, description, why suggested and the checks, and reads "Would you like to share it?" (matching the agent led template wording). Tests updated for the new order; proposalNotice removed from all desktop locales. * wisdom: American spelling, organization Product owner decision: user facing copy uses American spelling. Changes "Your organisation" to "Your organization" in the chat notice, the Hermes written card opener, the desktop and web strings, and the tests that assert them. Identifiers such as nas_organisation:* and the German and French locales are untouched. * wisdom: candidate card copy round 4 (owner review) Apply the product owner's round 4 copy decisions to the Hermes Collective Wisdom candidate card on Telegram, Slack, Desktop and the shared views: 1. Hermes-written cards are titled "Hermes Collective Wisdom" instead of the bare "Collective Wisdom". 2. The "Reusable skill ready to review" line is gone from the candidate card (Telegram rich card and plain fallback, legacy agent-led share template). 3. The skill name and description are labelled: "Skill name: " and "What it does: " (Telegram, Slack, Desktop). 4. "Why suggested:" is now "Why others might benefit:". 5. A passing professionalism review reads "Safe to share at work ✓ (no inappropriate content found)" with no per-check bullets and no "Pass"; a failed review reads "Needs a look before sharing at work (possible inappropriate content)" and lists only the checks that flagged something. Pending/unavailable wording is unchanged. 6. Telegram button toasts: "Will ask later...", "Preparing more details...", "Sharing...". 7. Qualification reasons: "You used this skill consistently across many days." and "You've really refined this skill." 8. prompts/wisdom_candidate_review.md asks for a compelling editorial_name, a simple one_line_description and a compelling why_coworkers_benefit under 300 characters; "Be concise and convincing." becomes "Be concise and compelling: the goal is that the user wants to share it." Tests updated for the new strings; review_text() gains direct coverage. * wisdom: re-apply owner copy after rebase - Native share cards (advice_view/interaction_view): drop the approval line, ask "Would you like to share it?" as the last line after the checks - Hermes-written completion card titled "Hermes Collective Wisdom" - Qualification reasons use the owner wording (consistently across many days / really refined) - American spelling (organization) in remaining English copy - Desktop test asserts the current Share button; web test matches the returning notice * fix(wisdom): pin reconciled Gateway and verify Unicode hash vectors Pin Gateway 60cd2d6b613ae3cd4a6e65155d1142006d907e78 and byte-identical producer artifacts. Verify every content-order case and package-manifest binding. Validation: 186 focused Python tests, Ruff and contract verifier. * fix(wisdom): reconcile optional SDK tests and frontend lint * fix(wisdom): default to agent-written notification summaries * fix(wisdom): restore deferred install review and browse controls * feat(wisdom): inspect installed setup with exact package provenance * feat(wisdom): run native-approved installed setup steps with durable evidence * fix(wisdom): recover interrupted setup with explicit native consent * feat(wisdom): hand native installs into guided setup review * fix(wisdom): continue requested setup with fixed notification copy * fix(wisdom): preserve setup while waiting for a session model * fix(wisdom): expose canonical setup review controls on desktop * fix(wisdom): resume setup after recorded automatic updates * fix(wisdom): make missing setup prerequisites recheckable * chore(wisdom): align Agent with verified Gateway contract * fix(wisdom): stop guessing team slugs in portal links * fix(wisdom): retire pending advice on account sign-out * fix(wisdom): cancel advice after terminal account revocation * fix(wisdom): fence feed responses across account sign-out * fix(wisdom): checkpoint signed-out feed before reactivation * fix(wisdom): link proactive advice to scoped notification settings * fix(wisdom): coalesce queued publication recommendations by version * fix(wisdom): keep package review navigation local and deferable * fix(wisdom): reflect installed state in discovery controls * fix(wisdom): show exact checks before command confirmation * chore(wisdom): pin bounded analytics privacy contract * chore(wisdom): pin retired legacy notification contract * feat(wisdom): review publisher usage with exact sharing copy * fix(wisdom): align discovery and review check summaries * fix(wisdom): show expired consent before confirmation * fix(wisdom): require fresh review for legacy install controls * fix(wisdom): preserve review expiry across check toggles * fix(wisdom): retain update policy in native install reviews * fix(wisdom): surface failed native card edits * fix(wisdom): persist local command approval reviews * fix(wisdom): use saved approvals for messaging commands * test(wisdom): provide scan result in setup handoff fixture * test(wisdom): exercise Telegram approvals with saved review state * fix(wisdom): retain suppression policy for offline deferral * fix(wisdom): reconsider candidates after deferred suppression expires * fix(wisdom): bind review checks and report verified readiness separately * fix(wisdom): persist accepted publication intent and recover exact outcomes * fix(sync): pin UTF-8 tree ordering across writers * chore(wisdom): pin organisation-scoped Gateway authorization * fix(wisdom): restrict consent delivery to user-facing sessions * chore(wisdom): refresh reviewed Gateway contract pin * fix(wisdom): preserve kept tools in Blank Slate exclusions * test(auth): reset anonymous fixture with a profile-scoped cache * fix(wisdom): gate local surfaces and work on current profile entitlement * fix(wisdom): invalidate quiet tool cache on entitlement changes * test(wisdom): authorize local consent gateway fixtures * fix(wisdom): keep entitlement decoding free of native crypto imports * test(wisdom): provide local entitlement to demo CLI subprocess * ci: leave upstream workflow unchanged in Wisdom PR * fix(wisdom): ship package and contracts in Nix wheels --------- Co-authored-by: hbizi <36184542+hbizi@users.noreply.github.com> --- CONTRIBUTING.md | 8 + agent/learn_prompt.py | 6 + agent/skill_utils.py | 128 +- apps/desktop/src/api/wisdom.ts | 577 + .../hooks/use-slash-completions.test.tsx | 26 + apps/desktop/src/app/chat/index.tsx | 11 +- .../src/app/skills/collective-tab.test.tsx | 610 + .../desktop/src/app/skills/collective-tab.tsx | 1017 ++ apps/desktop/src/app/skills/index.test.tsx | 169 +- apps/desktop/src/app/skills/index.tsx | 121 +- .../src/app/skills/wisdom-file-editor.tsx | 73 + .../src/app/skills/wisdom-manifest-editor.tsx | 598 + .../src/app/skills/wisdom-manifest.test.ts | 52 + .../desktop/src/app/skills/wisdom-manifest.ts | 255 + .../assistant-ui/thread/edit-context.test.tsx | 31 +- .../components/assistant-ui/thread/index.tsx | 29 +- .../components/assistant-ui/thread/list.tsx | 3 + .../thread/system-message.test.tsx | 188 +- .../assistant-ui/thread/system-message.tsx | 27 +- .../thread/wisdom-command-output.tsx | 599 + .../wisdom-candidate-card.test.tsx | 271 + .../assistant-ui/wisdom-candidate-card.tsx | 215 + .../assistant-ui/wisdom-notice-card.test.tsx | 73 + .../assistant-ui/wisdom-notice-card.tsx | 69 + apps/desktop/src/components/wisdom-checks.tsx | 143 + .../src/components/wisdom-consent-details.tsx | 77 + .../components/wisdom-mediation-card.test.tsx | 296 + .../src/components/wisdom-mediation-card.tsx | 301 + .../wisdom-notification-settings.test.tsx | 107 + .../wisdom-notification-settings.tsx | 168 + .../wisdom-notifications-card.test.tsx | 102 + .../components/wisdom-notifications-card.tsx | 148 + .../components/wisdom-publication-review.tsx | 302 + .../src/components/wisdom-setup-review.tsx | 76 + .../components/wisdom-sync-status.test.tsx | 55 + .../src/components/wisdom-sync-status.tsx | 122 + .../src/hermes-capability-scope.test.ts | 51 +- apps/desktop/src/hermes.test.ts | 39 + apps/desktop/src/hermes.ts | 1 + apps/desktop/src/i18n/ar.ts | 171 + apps/desktop/src/i18n/en.ts | 197 + apps/desktop/src/i18n/ja.ts | 198 + apps/desktop/src/i18n/ru.ts | 39 + apps/desktop/src/i18n/types.ts | 171 + apps/desktop/src/i18n/zh-hant.ts | 192 + apps/desktop/src/i18n/zh.ts | 193 + .../src/lib/desktop-slash-commands.test.ts | 18 +- apps/desktop/src/types/hermes.ts | 9 + apps/shared/src/index.ts | 9 + apps/shared/src/wisdom-mute.ts | 224 + apps/shared/src/wisdom-sync.ts | 64 + cli.py | 4 +- gateway/platforms/api_server.py | 6 +- gateway/platforms/base.py | 27 +- gateway/run.py | 11 +- gateway/run_adapters.py | 16 +- gateway/run_busy.py | 2 +- gateway/run_goals.py | 6 + gateway/run_inbound.py | 2 + gateway/run_turn.py | 10 + gateway/run_wisdom.py | 168 + gateway/wisdom_command.py | 1814 +++ gateway/wisdom_command_consent.py | 61 + gateway/wisdom_mediation.py | 242 + gateway/wisdom_publication_cards.py | 49 + hermes_cli/active_sessions.py | 13 +- hermes_cli/auth.py | 8 +- hermes_cli/cli_info_mixin.py | 4 +- hermes_cli/cli_wisdom_mixin.py | 35 + hermes_cli/commands.py | 73 +- hermes_cli/commands_completion.py | 10 +- hermes_cli/commands_platforms.py | 4 +- hermes_cli/config_defaults.py | 30 + hermes_cli/main.py | 4 +- hermes_cli/setup_quick.py | 9 +- hermes_cli/subcommands/wisdom.py | 424 + hermes_cli/web_models.py | 150 +- hermes_cli/web_routers/skills.py | 23 +- hermes_cli/web_routers/wisdom.py | 542 + hermes_cli/web_server.py | 5 + hermes_cli/web_server_lifecycle.py | 25 + hermes_wisdom/__init__.py | 5 + hermes_wisdom/account_session.py | 127 + hermes_wisdom/agent_led/__init__.py | 13 + hermes_wisdom/agent_led/actions.py | 92 + hermes_wisdom/agent_led/agent.py | 198 + hermes_wisdom/agent_led/evidence.py | 264 + hermes_wisdom/agent_led/history.py | 214 + hermes_wisdom/agent_led/install_flow.py | 166 + hermes_wisdom/agent_led/notify.py | 346 + hermes_wisdom/agent_led/policy.py | 161 + hermes_wisdom/agent_led/privacy.py | 42 + hermes_wisdom/agent_led/render.py | 118 + hermes_wisdom/agent_led/schemas.py | 367 + hermes_wisdom/agent_led/setup_document.py | 38 + hermes_wisdom/agent_led/share_flow.py | 486 + hermes_wisdom/agent_led/templates.py | 308 + hermes_wisdom/client.py | 887 ++ hermes_wisdom/client_delivery.py | 103 + hermes_wisdom/client_outcome.py | 33 + hermes_wisdom/compatibility.py | 358 + hermes_wisdom/consent.py | 1105 ++ hermes_wisdom/consumption.py | 1503 ++ hermes_wisdom/contract.py | 221 + .../contracts/canonical-hash-vectors.v1.json | 55 + hermes_wisdom/contracts/gateway-openapi.json | 13295 ++++++++++++++++ .../contracts/skill-manifest.schema.v1.json | 163 + hermes_wisdom/delivery.py | 90 + hermes_wisdom/delivery_outbox.py | 356 + hermes_wisdom/editorial.py | 255 + hermes_wisdom/entitlement.py | 91 + hermes_wisdom/installed_setup.py | 185 + hermes_wisdom/local_security.py | 73 + hermes_wisdom/mediation.py | 878 + hermes_wisdom/mediation_store.py | 758 + hermes_wisdom/mediation_view.py | 594 + hermes_wisdom/notice.py | 21 + hermes_wisdom/operation_outbox.py | 264 + hermes_wisdom/package.py | 570 + hermes_wisdom/portal_links.py | 21 + hermes_wisdom/preferences.py | 545 + hermes_wisdom/professionalism.py | 338 + hermes_wisdom/publication_cards.py | 206 + hermes_wisdom/publisher_evidence.py | 41 + hermes_wisdom/qualification.py | 465 + hermes_wisdom/review_presentation.py | 137 + hermes_wisdom/service.py | 2945 ++++ hermes_wisdom/setup_continuation.py | 72 + hermes_wisdom/setup_execution.py | 308 + hermes_wisdom/setup_handoff.py | 85 + hermes_wisdom/setup_queue.py | 157 + hermes_wisdom/share_queue.py | 144 + hermes_wisdom/store.py | 1804 +++ hermes_wisdom/sync_recovery.py | 118 + hermes_wisdom/weekly_queue.py | 445 + model_tools.py | 13 +- package-lock.json | 1 + plugins/platforms/slack/adapter.py | 8 +- plugins/platforms/slack/wisdom_adapter.py | 842 + plugins/platforms/slack/wisdom_blocks.py | 118 + plugins/platforms/telegram/adapter.py | 29 +- plugins/platforms/telegram/wisdom_adapter.py | 1156 ++ prompts/wisdom_candidate_review.md | 69 + prompts/wisdom_recipient_recommendation.md | 50 + prompts/wisdom_share_packaging.md | 33 + pyproject.toml | 3 +- scripts/lib/wisdom-demo-env.sh | 108 + scripts/verify_wisdom_contract.py | 99 + scripts/wisdom-demo-bin/hermes | 11 + scripts/wisdom-demo-env.sh | 56 + scripts/wisdom-demo-stack.sh | 730 + .../collective-wisdom-install/SKILL.md | 124 + .../hermes-agent-skill-authoring/SKILL.md | 3 + tests/agent/test_learn_prompt.py | 5 + tests/agent/test_wisdom_skill_namespace.py | 33 + tests/cli/test_slash_dispatch_table.py | 15 +- .../test_gateway_command_dispatch_minimal.py | 19 + tests/gateway/test_goal_status_notice.py | 82 + .../test_multiplex_interactive_auth.py | 57 + tests/gateway/test_slack_wisdom.py | 270 + .../gateway/test_telegram_approval_buttons.py | 636 +- tests/gateway/test_telegram_wisdom_command.py | 353 + tests/gateway/test_wisdom_command.py | 1167 ++ tests/gateway/test_wisdom_legacy_controls.py | 395 + tests/gateway/test_wisdom_local_consent.py | 89 + tests/gateway/test_wisdom_mediation.py | 739 + tests/gateway/test_wisdom_mute_controls.py | 232 + .../gateway/test_wisdom_publication_cards.py | 97 + tests/gateway/test_wisdom_runner_hooks.py | 151 + tests/hermes_cli/test_commands.py | 57 + .../test_dashboard_profiles_nav_label.py | 3 +- tests/hermes_cli/test_setup_blank_slate.py | 24 + tests/hermes_cli/test_wisdom_parser.py | 149 + tests/hermes_cli/test_wisdom_slash.py | 74 + tests/scripts/test_wisdom_demo_env.py | 156 + tests/scripts/test_wisdom_demo_stack.py | 282 + .../test_collective_wisdom_install_skill.py | 21 + tests/test_packaging_build_guard.py | 7 +- tests/test_tui_gateway_server.py | 245 +- .../tools/test_send_message_telegram_proxy.py | 28 + tests/tools/test_send_message_tool.py | 403 +- tests/tools/test_skill_manage_schema_diet.py | 2 + tests/tools/test_skill_usage.py | 59 + tests/tools/test_skill_view_dedup.py | 16 +- tests/tools/test_skills_hub.py | 11 +- tests/tools/test_skills_sync_client.py | 11 + tests/tools/test_skills_tool.py | 114 + .../test_telegram_send_message_caption.py | 28 + tests/tui_gateway/test_protocol.py | 8 +- tests/tui_gateway/test_relay_live_author.py | 13 +- .../test_slash_worker_profile_home.py | 34 +- tests/wisdom/entitlement_fixtures.py | 40 + tests/wisdom/local_auth.py | 22 + tests/wisdom/test_account_session.py | 92 + tests/wisdom/test_agent_led.py | 604 + tests/wisdom/test_agent_led_boundaries.py | 135 + tests/wisdom/test_agent_led_surfaces.py | 117 + tests/wisdom/test_automatic_setup.py | 186 + tests/wisdom/test_client.py | 696 + tests/wisdom/test_compatibility.py | 143 + tests/wisdom/test_consumption.py | 1091 ++ tests/wisdom/test_contract.py | 102 + tests/wisdom/test_delivery.py | 234 + tests/wisdom/test_delivery_outbox.py | 683 + tests/wisdom/test_editorial.py | 171 + tests/wisdom/test_entitlement.py | 190 + tests/wisdom/test_feed_account_lifecycle.py | 128 + tests/wisdom/test_feed_coalescing.py | 237 + tests/wisdom/test_feed_mediation.py | 657 + tests/wisdom/test_feed_reactivation.py | 212 + tests/wisdom/test_installed_setup.py | 233 + tests/wisdom/test_local_security.py | 70 + tests/wisdom/test_mediation.py | 1164 ++ tests/wisdom/test_mediation_store.py | 232 + tests/wisdom/test_mute_controls.py | 222 + tests/wisdom/test_mute_preferences.py | 222 + tests/wisdom/test_mute_web_api.py | 51 + tests/wisdom/test_native_install_policy.py | 129 + tests/wisdom/test_native_suppression.py | 58 + tests/wisdom/test_notification_defaults.py | 49 + tests/wisdom/test_operation_outbox.py | 544 + tests/wisdom/test_package.py | 438 + tests/wisdom/test_portal_links.py | 26 + tests/wisdom/test_preferences.py | 222 + tests/wisdom/test_professionalism.py | 218 + tests/wisdom/test_publication_cards.py | 273 + tests/wisdom/test_publication_recovery.py | 165 + tests/wisdom/test_publisher_evidence.py | 172 + tests/wisdom/test_qualification.py | 487 + tests/wisdom/test_review_presentation.py | 152 + tests/wisdom/test_service.py | 1752 ++ tests/wisdom/test_setup_execution.py | 711 + tests/wisdom/test_setup_prerequisites.py | 162 + tests/wisdom/test_share_queue.py | 755 + tests/wisdom/test_share_staging.py | 371 + tests/wisdom/test_store.py | 691 + tests/wisdom/test_sync_recovery.py | 191 + .../test_tool_definition_entitlement_cache.py | 69 + tests/wisdom/test_web_api.py | 517 + tests/wisdom/test_web_lifecycle.py | 63 + tests/wisdom/test_web_router_mount.py | 178 + tests/wisdom/test_weekly_queue.py | 614 + tools/delegate_tool_toolsets.py | 1 + tools/send_message_senders.py | 26 +- tools/skill_manager_tool.py | 9 +- tools/skill_usage.py | 14 + tools/skills_hub_models.py | 2 + tools/skills_hub_official.py | 15 +- tools/skills_sync_client_wire.py | 2 +- tools/skills_tool.py | 58 +- tools/wisdom_notifications.py | 348 + tools/wisdom_tool.py | 247 + toolsets.py | 7 +- tui_gateway/methods_complete.py | 1 + tui_gateway/methods_slash.py | 3 + tui_gateway/methods_tools.py | 27 +- tui_gateway/prompt_turn.py | 9 + tui_gateway/server.py | 12 +- tui_gateway/session_notifications.py | 4 + tui_gateway/session_wisdom.py | 166 + tui_gateway/wisdom_mediation.py | 191 + web/package.json | 1 + .../components/CollectiveWisdomPanel.test.tsx | 572 + web/src/components/CollectiveWisdomPanel.tsx | 1245 ++ web/src/components/SkillEditorDialog.tsx | 4 + .../components/WisdomAgentActivity.test.tsx | 29 + web/src/components/WisdomAgentActivity.tsx | 49 + web/src/components/WisdomChecks.tsx | 119 + web/src/components/WisdomFileEditor.tsx | 63 + web/src/components/WisdomManifestEditor.tsx | 628 + .../WisdomNotificationSettings.test.tsx | 99 + .../components/WisdomNotificationSettings.tsx | 152 + web/src/components/WisdomSyncStatus.test.tsx | 54 + web/src/components/WisdomSyncStatus.tsx | 111 + web/src/i18n/af.ts | 121 + web/src/i18n/ar.ts | 118 + web/src/i18n/de.ts | 123 + web/src/i18n/en.ts | 119 + web/src/i18n/es.ts | 122 + web/src/i18n/fr.ts | 121 + web/src/i18n/ga.ts | 122 + web/src/i18n/hu.ts | 120 + web/src/i18n/it.ts | 122 + web/src/i18n/ja.ts | 120 + web/src/i18n/ko.ts | 118 + web/src/i18n/pt.ts | 121 + web/src/i18n/ru.ts | 119 + web/src/i18n/tr.ts | 120 + web/src/i18n/types.ts | 93 + web/src/i18n/uk.ts | 119 + web/src/i18n/zh-hant.ts | 117 + web/src/i18n/zh.ts | 117 + web/src/lib/api.test.ts | 31 + web/src/lib/api.ts | 3505 ++-- web/src/lib/wisdom-manifest.test.ts | 68 + web/src/lib/wisdom-manifest.ts | 236 + web/src/lib/wisdom-mute.test.ts | 137 + web/src/lib/wisdom-sync.test.ts | 56 + web/src/pages/SkillsPage.test.tsx | 59 + web/src/pages/SkillsPage.tsx | 115 +- website/docs/reference/skills-catalog.md | 1 + .../user-guide/collective-wisdom-advice.md | 145 + website/docs/user-guide/messaging/slack.md | 43 + .../productivity-collective-wisdom-install.md | 141 + website/sidebars.ts | 1 + 305 files changed, 80674 insertions(+), 1783 deletions(-) create mode 100644 apps/desktop/src/api/wisdom.ts create mode 100644 apps/desktop/src/app/skills/collective-tab.test.tsx create mode 100644 apps/desktop/src/app/skills/collective-tab.tsx create mode 100644 apps/desktop/src/app/skills/wisdom-file-editor.tsx create mode 100644 apps/desktop/src/app/skills/wisdom-manifest-editor.tsx create mode 100644 apps/desktop/src/app/skills/wisdom-manifest.test.ts create mode 100644 apps/desktop/src/app/skills/wisdom-manifest.ts create mode 100644 apps/desktop/src/components/assistant-ui/thread/wisdom-command-output.tsx create mode 100644 apps/desktop/src/components/assistant-ui/wisdom-candidate-card.test.tsx create mode 100644 apps/desktop/src/components/assistant-ui/wisdom-candidate-card.tsx create mode 100644 apps/desktop/src/components/assistant-ui/wisdom-notice-card.test.tsx create mode 100644 apps/desktop/src/components/assistant-ui/wisdom-notice-card.tsx create mode 100644 apps/desktop/src/components/wisdom-checks.tsx create mode 100644 apps/desktop/src/components/wisdom-consent-details.tsx create mode 100644 apps/desktop/src/components/wisdom-mediation-card.test.tsx create mode 100644 apps/desktop/src/components/wisdom-mediation-card.tsx create mode 100644 apps/desktop/src/components/wisdom-notification-settings.test.tsx create mode 100644 apps/desktop/src/components/wisdom-notification-settings.tsx create mode 100644 apps/desktop/src/components/wisdom-notifications-card.test.tsx create mode 100644 apps/desktop/src/components/wisdom-notifications-card.tsx create mode 100644 apps/desktop/src/components/wisdom-publication-review.tsx create mode 100644 apps/desktop/src/components/wisdom-setup-review.tsx create mode 100644 apps/desktop/src/components/wisdom-sync-status.test.tsx create mode 100644 apps/desktop/src/components/wisdom-sync-status.tsx create mode 100644 apps/shared/src/wisdom-mute.ts create mode 100644 apps/shared/src/wisdom-sync.ts create mode 100644 gateway/run_wisdom.py create mode 100644 gateway/wisdom_command.py create mode 100644 gateway/wisdom_command_consent.py create mode 100644 gateway/wisdom_mediation.py create mode 100644 gateway/wisdom_publication_cards.py create mode 100644 hermes_cli/cli_wisdom_mixin.py create mode 100644 hermes_cli/subcommands/wisdom.py create mode 100644 hermes_cli/web_routers/wisdom.py create mode 100644 hermes_wisdom/__init__.py create mode 100644 hermes_wisdom/account_session.py create mode 100644 hermes_wisdom/agent_led/__init__.py create mode 100644 hermes_wisdom/agent_led/actions.py create mode 100644 hermes_wisdom/agent_led/agent.py create mode 100644 hermes_wisdom/agent_led/evidence.py create mode 100644 hermes_wisdom/agent_led/history.py create mode 100644 hermes_wisdom/agent_led/install_flow.py create mode 100644 hermes_wisdom/agent_led/notify.py create mode 100644 hermes_wisdom/agent_led/policy.py create mode 100644 hermes_wisdom/agent_led/privacy.py create mode 100644 hermes_wisdom/agent_led/render.py create mode 100644 hermes_wisdom/agent_led/schemas.py create mode 100644 hermes_wisdom/agent_led/setup_document.py create mode 100644 hermes_wisdom/agent_led/share_flow.py create mode 100644 hermes_wisdom/agent_led/templates.py create mode 100644 hermes_wisdom/client.py create mode 100644 hermes_wisdom/client_delivery.py create mode 100644 hermes_wisdom/client_outcome.py create mode 100644 hermes_wisdom/compatibility.py create mode 100644 hermes_wisdom/consent.py create mode 100644 hermes_wisdom/consumption.py create mode 100644 hermes_wisdom/contract.py create mode 100644 hermes_wisdom/contracts/canonical-hash-vectors.v1.json create mode 100644 hermes_wisdom/contracts/gateway-openapi.json create mode 100644 hermes_wisdom/contracts/skill-manifest.schema.v1.json create mode 100644 hermes_wisdom/delivery.py create mode 100644 hermes_wisdom/delivery_outbox.py create mode 100644 hermes_wisdom/editorial.py create mode 100644 hermes_wisdom/entitlement.py create mode 100644 hermes_wisdom/installed_setup.py create mode 100644 hermes_wisdom/local_security.py create mode 100644 hermes_wisdom/mediation.py create mode 100644 hermes_wisdom/mediation_store.py create mode 100644 hermes_wisdom/mediation_view.py create mode 100644 hermes_wisdom/notice.py create mode 100644 hermes_wisdom/operation_outbox.py create mode 100644 hermes_wisdom/package.py create mode 100644 hermes_wisdom/portal_links.py create mode 100644 hermes_wisdom/preferences.py create mode 100644 hermes_wisdom/professionalism.py create mode 100644 hermes_wisdom/publication_cards.py create mode 100644 hermes_wisdom/publisher_evidence.py create mode 100644 hermes_wisdom/qualification.py create mode 100644 hermes_wisdom/review_presentation.py create mode 100644 hermes_wisdom/service.py create mode 100644 hermes_wisdom/setup_continuation.py create mode 100644 hermes_wisdom/setup_execution.py create mode 100644 hermes_wisdom/setup_handoff.py create mode 100644 hermes_wisdom/setup_queue.py create mode 100644 hermes_wisdom/share_queue.py create mode 100644 hermes_wisdom/store.py create mode 100644 hermes_wisdom/sync_recovery.py create mode 100644 hermes_wisdom/weekly_queue.py create mode 100644 plugins/platforms/slack/wisdom_adapter.py create mode 100644 plugins/platforms/slack/wisdom_blocks.py create mode 100644 plugins/platforms/telegram/wisdom_adapter.py create mode 100644 prompts/wisdom_candidate_review.md create mode 100644 prompts/wisdom_recipient_recommendation.md create mode 100644 prompts/wisdom_share_packaging.md create mode 100644 scripts/lib/wisdom-demo-env.sh create mode 100644 scripts/verify_wisdom_contract.py create mode 100755 scripts/wisdom-demo-bin/hermes create mode 100755 scripts/wisdom-demo-env.sh create mode 100755 scripts/wisdom-demo-stack.sh create mode 100644 skills/productivity/collective-wisdom-install/SKILL.md create mode 100644 tests/agent/test_wisdom_skill_namespace.py create mode 100644 tests/gateway/test_slack_wisdom.py create mode 100644 tests/gateway/test_telegram_wisdom_command.py create mode 100644 tests/gateway/test_wisdom_command.py create mode 100644 tests/gateway/test_wisdom_legacy_controls.py create mode 100644 tests/gateway/test_wisdom_local_consent.py create mode 100644 tests/gateway/test_wisdom_mediation.py create mode 100644 tests/gateway/test_wisdom_mute_controls.py create mode 100644 tests/gateway/test_wisdom_publication_cards.py create mode 100644 tests/gateway/test_wisdom_runner_hooks.py create mode 100644 tests/hermes_cli/test_wisdom_parser.py create mode 100644 tests/hermes_cli/test_wisdom_slash.py create mode 100644 tests/scripts/test_wisdom_demo_env.py create mode 100644 tests/scripts/test_wisdom_demo_stack.py create mode 100644 tests/skills/test_collective_wisdom_install_skill.py create mode 100644 tests/wisdom/entitlement_fixtures.py create mode 100644 tests/wisdom/local_auth.py create mode 100644 tests/wisdom/test_account_session.py create mode 100644 tests/wisdom/test_agent_led.py create mode 100644 tests/wisdom/test_agent_led_boundaries.py create mode 100644 tests/wisdom/test_agent_led_surfaces.py create mode 100644 tests/wisdom/test_automatic_setup.py create mode 100644 tests/wisdom/test_client.py create mode 100644 tests/wisdom/test_compatibility.py create mode 100644 tests/wisdom/test_consumption.py create mode 100644 tests/wisdom/test_contract.py create mode 100644 tests/wisdom/test_delivery.py create mode 100644 tests/wisdom/test_delivery_outbox.py create mode 100644 tests/wisdom/test_editorial.py create mode 100644 tests/wisdom/test_entitlement.py create mode 100644 tests/wisdom/test_feed_account_lifecycle.py create mode 100644 tests/wisdom/test_feed_coalescing.py create mode 100644 tests/wisdom/test_feed_mediation.py create mode 100644 tests/wisdom/test_feed_reactivation.py create mode 100644 tests/wisdom/test_installed_setup.py create mode 100644 tests/wisdom/test_local_security.py create mode 100644 tests/wisdom/test_mediation.py create mode 100644 tests/wisdom/test_mediation_store.py create mode 100644 tests/wisdom/test_mute_controls.py create mode 100644 tests/wisdom/test_mute_preferences.py create mode 100644 tests/wisdom/test_mute_web_api.py create mode 100644 tests/wisdom/test_native_install_policy.py create mode 100644 tests/wisdom/test_native_suppression.py create mode 100644 tests/wisdom/test_notification_defaults.py create mode 100644 tests/wisdom/test_operation_outbox.py create mode 100644 tests/wisdom/test_package.py create mode 100644 tests/wisdom/test_portal_links.py create mode 100644 tests/wisdom/test_preferences.py create mode 100644 tests/wisdom/test_professionalism.py create mode 100644 tests/wisdom/test_publication_cards.py create mode 100644 tests/wisdom/test_publication_recovery.py create mode 100644 tests/wisdom/test_publisher_evidence.py create mode 100644 tests/wisdom/test_qualification.py create mode 100644 tests/wisdom/test_review_presentation.py create mode 100644 tests/wisdom/test_service.py create mode 100644 tests/wisdom/test_setup_execution.py create mode 100644 tests/wisdom/test_setup_prerequisites.py create mode 100644 tests/wisdom/test_share_queue.py create mode 100644 tests/wisdom/test_share_staging.py create mode 100644 tests/wisdom/test_store.py create mode 100644 tests/wisdom/test_sync_recovery.py create mode 100644 tests/wisdom/test_tool_definition_entitlement_cache.py create mode 100644 tests/wisdom/test_web_api.py create mode 100644 tests/wisdom/test_web_lifecycle.py create mode 100644 tests/wisdom/test_web_router_mount.py create mode 100644 tests/wisdom/test_weekly_queue.py create mode 100644 tools/wisdom_notifications.py create mode 100644 tools/wisdom_tool.py create mode 100644 tui_gateway/session_wisdom.py create mode 100644 tui_gateway/wisdom_mediation.py create mode 100644 web/src/components/CollectiveWisdomPanel.test.tsx create mode 100644 web/src/components/CollectiveWisdomPanel.tsx create mode 100644 web/src/components/WisdomAgentActivity.test.tsx create mode 100644 web/src/components/WisdomAgentActivity.tsx create mode 100644 web/src/components/WisdomChecks.tsx create mode 100644 web/src/components/WisdomFileEditor.tsx create mode 100644 web/src/components/WisdomManifestEditor.tsx create mode 100644 web/src/components/WisdomNotificationSettings.test.tsx create mode 100644 web/src/components/WisdomNotificationSettings.tsx create mode 100644 web/src/components/WisdomSyncStatus.test.tsx create mode 100644 web/src/components/WisdomSyncStatus.tsx create mode 100644 web/src/lib/wisdom-manifest.test.ts create mode 100644 web/src/lib/wisdom-manifest.ts create mode 100644 web/src/lib/wisdom-mute.test.ts create mode 100644 web/src/lib/wisdom-sync.test.ts create mode 100644 web/src/pages/SkillsPage.test.tsx create mode 100644 website/docs/user-guide/collective-wisdom-advice.md create mode 100644 website/docs/user-guide/skills/bundled/productivity/productivity-collective-wisdom-install.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0e6761ee18..b0047dbd48 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -451,6 +451,8 @@ prerequisites: # Optional legacy runtime requirements commands: [curl, jq] # Advisory only; does not hide the skill metadata: hermes: + editorial_name: My Skill # Optional human-readable UI title + editorial_description: What this skill helps a person accomplish. tags: [Category, Subcategory, Keywords] related_skills: [other-skill-name] fallback_for_toolsets: [web] # Optional — show only when toolset is unavailable @@ -483,6 +485,12 @@ Known failure modes and how to handle them. How the agent confirms it worked. ``` +`metadata.hermes.editorial_name` and `editorial_description` are optional, +human-facing presentation copy. They may use natural titles and fuller prose +than the routing-focused top-level fields. Hermes continues to identify and +route skills with `name` and `description`; UIs fall back to that canonical +pair when editorial copy is absent. + ### Platform-specific skills Skills can declare which OS platforms they support via the `platforms` frontmatter field. Skills with this field are automatically hidden from the system prompt, `skills_list()`, and slash commands on incompatible platforms. diff --git a/agent/learn_prompt.py b/agent/learn_prompt.py index db50f95aa3..676007af7f 100644 --- a/agent/learn_prompt.py +++ b/agent/learn_prompt.py @@ -38,6 +38,12 @@ Frontmatter: cross-platform first (tempfile.gettempdir(), pathlib.Path, psutil); gate only when the dependency is genuinely platform-bound. Omit the field for portable skills. +- metadata.hermes.editorial_name: a concise, human-readable title for app + surfaces. Use normal title casing and spaces; this is presentation copy, not + the agent-facing skill identifier. +- metadata.hermes.editorial_description: one or two plain-language sentences + explaining the skill to a person browsing it. This is presentation copy and + does not replace the routing-focused top-level description. - metadata.hermes.tags: a few Capitalized, Relevant, Tags. Body section order (omit a section only if it genuinely has no content): diff --git a/agent/skill_utils.py b/agent/skill_utils.py index fe0b06c69f..9c06c05df2 100644 --- a/agent/skill_utils.py +++ b/agent/skill_utils.py @@ -33,6 +33,12 @@ ORG_ACTIVE_MARKER = ".active_org" ORG_PROVENANCE_FILE = ".org-provenance.json" ORG_BASELINE_FILE = ".org-baseline.json" # upstream fingerprint; detects local edits +# Collective Wisdom managed installs are intentionally separate from the M2 +# whole-org mirror. The only writer of this marker is the Wisdom setup/client +# path after the Gateway has accepted the profile's installation identity. +WISDOM_MANAGED_DIR_NAME = "_wisdom" +WISDOM_ACTIVE_MARKER = ".active_org" + def read_active_org_id(skills_dir: Path) -> Optional[str]: """The org id whose mirror may resolve, or None (no org skills load).""" @@ -43,6 +49,27 @@ def read_active_org_id(skills_dir: Path) -> Optional[str]: return None +def read_active_wisdom_org_id(skills_dir: Path) -> Optional[str]: + """The last Gateway-verified org whose managed Wisdom skills may load.""" + try: + marker = skills_dir / WISDOM_MANAGED_DIR_NAME / WISDOM_ACTIVE_MARKER + if not marker.exists(): + return None + value = marker.read_text(encoding="utf-8").strip() + return value or None + except OSError: + return None + + +def is_wisdom_managed_path(path, skills_dir: Path) -> bool: + """True when *path* is below ``_wisdom//``.""" + try: + rel = Path(path).resolve().relative_to(Path(skills_dir).resolve()) + except (OSError, ValueError): + return False + return bool(rel.parts) and rel.parts[0] == WISDOM_MANAGED_DIR_NAME + + def _org_rel_parts(path, skills_dir: Path) -> Tuple[str, ...]: """Path parts of *path* relative to *skills_dir* if it is under ``_org/``, else ``()``.""" try: @@ -733,22 +760,113 @@ def is_skill_description_truncated_for_prompt(frontmatter: Dict[str, Any]) -> bo return len(_normalize_skill_description(frontmatter)) > SKILL_PROMPT_DESC_LIMIT +def extract_skill_editorial_metadata( + frontmatter: Dict[str, Any], + *, + fallback_name: str, + fallback_description: str, +) -> Dict[str, str]: + """Resolve optional human-facing skill copy without changing agent metadata. + + ``name`` and ``description`` remain the canonical agent-facing routing + fields. Hermes UIs may use the optional values under ``metadata.hermes``; + older and third-party skills fall back to the canonical pair. + """ + metadata = frontmatter.get("metadata") + hermes = metadata.get("hermes") if isinstance(metadata, dict) else None + if not isinstance(hermes, dict): + hermes = {} + + editorial_name = hermes.get("editorial_name") + editorial_description = hermes.get("editorial_description") + return { + "editorial_name": ( + editorial_name.strip() + if isinstance(editorial_name, str) and editorial_name.strip() + else fallback_name + ), + "editorial_description": ( + editorial_description.strip() + if isinstance(editorial_description, str) + and editorial_description.strip() + else fallback_description + ), + } + + +def load_skill_editorial_metadata( + skill_path: Path, + *, + fallback_name: str | None = None, + fallback_description: str = "", +) -> Dict[str, str]: + """Load human-facing copy from a skill directory with safe fallbacks.""" + canonical_name = fallback_name or skill_path.name + canonical_description = fallback_description + try: + frontmatter, _body = parse_frontmatter( + (skill_path / "SKILL.md").read_text(encoding="utf-8") + ) + name = frontmatter.get("name") + description = frontmatter.get("description") + if isinstance(name, str) and name.strip(): + canonical_name = name.strip() + if isinstance(description, str) and description.strip(): + canonical_description = description.strip() + return extract_skill_editorial_metadata( + frontmatter, + fallback_name=canonical_name, + fallback_description=canonical_description, + ) + except (OSError, UnicodeError, ValueError): + return { + "editorial_name": canonical_name, + "editorial_description": canonical_description, + } + + +# ── File iteration ──────────────────────────────────────────────────────── + + def iter_skill_index_files(skills_dir: Path, filename: str): - """Walk skills_dir yielding sorted paths matching *filename*; prunes - EXCLUDED_SKILL_DIRS and support dirs of skill roots. Org mirrors are - TOKEN-GATED: only the active org's subdir is walked, so leaving an org - stops its skills resolving without manual cleanup.""" + """Walk skills_dir yielding sorted paths matching *filename*. + + Excludes Hermes metadata, VCS, virtualenv/dependency, cache, and skill + support directories. Support directories (references/templates/assets/ + scripts) can contain arbitrary markdown and even archived package + ``SKILL.md`` files, but they are progressive-disclosure data loaded through + ``skill_view(..., file_path=...)`` rather than active skill roots. + + M2 org mirrors (``_org/``) and Collective Wisdom installs + (``_wisdom/``): TOKEN-GATED resolution. Only the active org's + subdir (per the sync-client-written ``.active_org`` marker) is walked; + every other ``_org//`` (stale mirror from a previous org, or no + marker at all) is pruned — leave an org and its skills stop resolving, + without any manual cleanup. + """ skills_dir_str = str(skills_dir) active_org = read_active_org_id(skills_dir) + active_wisdom_org = read_active_wisdom_org_id(skills_dir) org_root = os.path.join(skills_dir_str, ORG_MIRROR_DIR_NAME) + wisdom_root = os.path.join(skills_dir_str, WISDOM_MANAGED_DIR_NAME) matches: list[str] = [] for root, dirs, files in os.walk(skills_dir_str, followlinks=True): has_skill_md = "SKILL.md" in files if root == skills_dir_str and ORG_MIRROR_DIR_NAME in dirs and active_org is None: dirs.remove(ORG_MIRROR_DIR_NAME) + if root == skills_dir_str and WISDOM_MANAGED_DIR_NAME in dirs and active_wisdom_org is None: + dirs.remove(WISDOM_MANAGED_DIR_NAME) elif root == org_root: dirs[:] = [d for d in dirs if d == active_org] - dirs[:] = [d for d in dirs if d not in EXCLUDED_SKILL_DIRS and not (has_skill_md and d in SKILL_SUPPORT_DIRS)] + elif root == wisdom_root: + # Inside _wisdom/: descend ONLY into the last Gateway-verified org. + dirs[:] = [d for d in dirs if d == active_wisdom_org] + dirs[:] = [ + d + for d in dirs + if d not in EXCLUDED_SKILL_DIRS + and not (has_skill_md and d in SKILL_SUPPORT_DIRS) + ] if filename in files: matches.append(os.path.join(root, filename)) yield from map(Path, sorted(matches)) diff --git a/apps/desktop/src/api/wisdom.ts b/apps/desktop/src/api/wisdom.ts new file mode 100644 index 0000000000..5e46ceb9bb --- /dev/null +++ b/apps/desktop/src/api/wisdom.ts @@ -0,0 +1,577 @@ +import type { WisdomMuteControl, WisdomMuteDuration, WisdomMuteSnapshot, WisdomSyncSnapshot } from '@hermes/shared' + +import type { ActionResponse } from '@/types/hermes' + +import { capabilityScoped, type ProfileScope } from './client' + +export type WisdomReviewStatus = 'advisory' | 'blocked' | 'pass' | 'pending' | 'retry' | 'running' | 'unavailable' + +export interface WisdomReviewCheckRow { + key: string + label?: string + status: WisdomReviewStatus + finding_count: number + details: string[] +} + +export interface WisdomReviewCheck { + source?: 'local_preflight' | string + schema_version?: number + status: WisdomReviewStatus + summary?: string + checks?: WisdomReviewCheckRow[] + provenance?: { kind: 'agent_assessed'; model: null | string; provider: null | string } +} + +export interface WisdomEntitlement { + entitled: boolean + org_id: null | string + scopes: string[] + expires_at: null | number +} + +export interface WisdomStatus { + configured: boolean + setup_required_reason?: 'not_configured' | 'organization_changed' | null + gateway_available: boolean + capability_advertised: boolean + verified_org_id: null | string + authenticated_org_id?: null | string + display_scopes: string[] + error?: null | string +} + +export interface WisdomCandidate { + local_skill_id: string + name: string + editorial_name?: string + editorial_description?: string + path: string + content_hash: string + eligibility: 'eligible' | 'instruction_only_fork_required' + reason: null | string + qualification: string + qualification_sequence: number | null + notice_variant: 'first' | 'returning' | null + organization_name: string | null + contribution_state: 'new' | 'prepared' + professionalism_check?: null | WisdomReviewCheck +} + +export interface WisdomCandidateEvent { + id: string + kind: 'wisdom.candidate' + session_id: null | string + task_id: null | string + content_hash: string + qualification_sequence: number + notice_variant: 'first' | 'returning' + organization_name: string | null + payload: { + skill_name: string + editorial_name?: string + editorial_description?: string + qualification: string + local_reasons: Record + consent_required: true + networked: false + } +} + +export interface WisdomSkillSummary { + id: string + slug: string + state: string + latest_version: null | number + author_description: null | string + install_count: number + scan_verdict?: null | string + system_spec?: null | Record + security_check?: null | WisdomReviewCheck + professionalism_check?: null | WisdomReviewCheck +} + +export interface WisdomDiscovery { + skills: WisdomSkillSummary[] + next_cursor: null | string +} + +export interface WisdomDraft { + id: string + slug: string + state: string + authorDescription: null | string + explanation?: null | string + scan?: null | Record + scanVerdict: null | string + systemSpec?: null | Record + updatedAt: string + security_check?: null | WisdomReviewCheck + professionalism_check?: null | WisdomReviewCheck +} + +export interface WisdomPreparedDraft { + hashes: WisdomDraftReview['hashes'] + network_submission: false + local_draft_id: string + overlay_path: string + drafted_description: string + files: WisdomDraftReview['files'] + local_scan: WisdomLocalScan + system_specification: Record + next_step: string + professionalism_check: WisdomReviewCheck +} + +export type WisdomCandidatePreparation = + | { + stage: 'prepared' + prepared: WisdomPreparedDraft + local_skill_id: string + skill_name: string + } + | { stage: 'review'; review: WisdomDraftReview } + +export interface WisdomLocalScan { + guard: Record + skill_evaluator: Record +} + +export interface WisdomSubmittedDraft { + draft: WisdomDraft + local_scan: WisdomLocalScan + notice: string + professionalism_check: WisdomReviewCheck +} + +export interface WisdomDraftReview { + draft: WisdomDraft & Record + effective_policy: Record + files: Array<{ content_utf8: string; hash: string; mode: 'exec' | 'file'; path: string }> + hashes: { author_description: string; content: string; package_manifest: string } + receipt: null | string +} + +export type WisdomPublicationReview = WisdomDraftReview & { + publication_mode: 'open' | 'managed' | 'moderated' + portal_url?: string +} +export interface WisdomPublicationResult { + draft_id: string + publication_state: 'pending_moderation' | 'published' + portal_url: string +} + +export const reviewWisdomPublication = (draftId: string, profile?: ProfileScope): Promise => + request('/api/wisdom/publication/review', profile, { + method: 'POST', + timeoutMs: 120_000, + body: { draft_id: draftId } + }) + +export const submitWisdomPublication = ( + review: WisdomPublicationReview, + profile?: ProfileScope, + consent?: { interaction_id: string; session_id: string } +): Promise => + request('/api/wisdom/publication/submit', profile, { + method: 'POST', + timeoutMs: 120_000, + body: { + draft_id: review.draft.id, + expected_hashes: review.hashes, + publication_mode: review.publication_mode, + ...consent + } + }) + +export const prepareWisdomConsentPublication = ( + interactionId: string, + sessionId: string, + profile?: ProfileScope +): Promise<{ draft_id: string }> => + request('/api/wisdom/consent/publication-review', profile, { + method: 'POST', + timeoutMs: 120_000, + body: { interaction_id: interactionId, session_id: sessionId, action: 'inspect' } + }) + +export interface WisdomEditedFile { + path: string + content_utf8: string +} + +export interface WisdomRevisedDraft { + draft: WisdomDraft & Record + local_scan: WisdomLocalScan + notice: string + professionalism_check: WisdomReviewCheck +} + +export interface WisdomSkillDetail { + latest_version_detail?: Record + local_compatibility?: Record + local_installation?: null | Record + portal_url?: string | null + skill: Record + versions: Array> +} + +export interface WisdomVersionDetail { + local_compatibility?: Record + local_installation?: null | Record + portal_url?: string | null + skill: Record + version: Record +} + +export interface WisdomVersionContent { + commit: string + content_hash: string + files: Array<{ content_utf8: string; hash: string; mode: 'exec' | 'file'; path: string }> +} + +export type WisdomUpdateMode = 'AUTO_WITH_NOTICE' | 'MANUAL' | 'REQUIRED' + +export interface WisdomManagedInstall { + skill_id: string + slug: string + version: number + update_mode: WisdomUpdateMode + state: string + target_path: string +} + +export type WisdomNotificationCategory = + 'installed' | 'new_skill' | 'publication_decision' | 'unavailable' | 'update_available' | 'updated' + +export interface WisdomNotification { + category: WisdomNotificationCategory + editorial_description?: string | null + editorial_name?: string | null + event_id: string + kind: string + moderation_note?: string | null + occurred_at?: string | null + portal_url?: string | null + skill_id: string + skill_name: string + source_event_ids: string[] + state?: string | null + version?: number | null +} + +export interface WisdomInstallations { + delivery_mode?: 'agent' | 'fixed' + installations: WisdomManagedInstall[] + notifications: WisdomNotification[] +} + +export type WisdomConsentAction = + 'inspect' | `inspect.${number}` | 'defer' | 'confirm' | 'recheck' | 'setup.status' | 'setup.recover' | 'setup.clear' + +export interface WisdomConsentInteraction { + id: string + assessment_id: string + state: string + operation: 'share' | 'install' | 'update' | 'publish' | 'setup' + expires_at: number + actions: ('defer' | 'inspect' | 'confirm')[] + deferred?: boolean + deferred_surfaces?: string[] + inspection?: { path: string; content: string; hash: string; description: string; page: number; page_count: number } + result?: { packaging_state?: 'queued' | 'ready' | 'failed' } + setup_review?: { + summary: string + detail: string + command: string + command_label: string + actions: { action: WisdomConsentAction; label: string; primary: boolean }[] + } + facts: { + step?: { phase: 'prerequisite' | 'setup' | 'verify'; index: number; command: string } + setup_instruction?: string + setup_explanation?: string + slug?: string + version?: number + editorial_name?: string | null + editorial_description?: string | null + compatibility?: { outcome: string } + modified?: boolean + sensitive_expansion?: string[] + security_check?: WisdomReviewCheck | null + professionalism_check?: WisdomReviewCheck | null + file_names?: string[] + } +} + +export interface WisdomMediationActivity { + mode: 'fixed' | 'agent' + assessments: { + id: string + state: string + owner_session: string | null + advice: null | { title: string; explanation: string; relevance: 'recommend' | 'digest' } + }[] + interactions: WisdomConsentInteraction[] +} + +export type WisdomInstallationCheckState = + 'archived' | 'current' | 'not_recorded' | 'taken_down' | 'update_available' | 'updated' + +export interface WisdomInstallationCheck { + skill_id: string + state: WisdomInstallationCheckState + plan?: WisdomActionPlan + result?: Record +} + +export interface WisdomCheckResult { + installations: WisdomInstallationCheck[] + qualification_events?: unknown[] + feed?: Record + owner_decisions?: Record + telegram?: Record +} + +export interface WisdomActionPlan { + receipt?: string + state?: string + skill_id: string + version?: number + compatibility?: { outcome: string; reasons?: string[] } + sensitive_expansion?: string[] + modified?: boolean + update_mode?: string + allowed?: boolean +} + +const request = ( + path: string, + profile?: ProfileScope, + init?: { body?: unknown; method?: string; timeoutMs?: number } +): Promise => + window.hermesDesktop.api({ + ...capabilityScoped(profile), + path, + method: init?.method, + timeoutMs: init?.timeoutMs, + body: init?.body + }) + +export const getWisdomStatus = (profile?: ProfileScope): Promise => request('/api/wisdom/status', profile) +export const getWisdomEntitlement = (profile?: ProfileScope): Promise => + request('/api/wisdom/entitlement', profile) + + +export const getWisdomSync = (profile?: ProfileScope): Promise => + request('/api/wisdom/sync', profile) +export const retryWisdomSync = (profile?: ProfileScope): Promise => + request('/api/wisdom/sync/retry', profile, { method: 'POST', body: {}, timeoutMs: 120_000 }) + +export const getWisdomMute = (profile?: ProfileScope): Promise => + request('/api/wisdom/mute', profile) +export const prepareWisdomMute = (profile?: ProfileScope): Promise => + request('/api/wisdom/mute/prepare', profile, { method: 'POST', body: {} }) +export const chooseWisdomMute = ( + controlId: string, + duration: WisdomMuteDuration, + profile?: ProfileScope +): Promise => + request('/api/wisdom/mute/choose', profile, { method: 'POST', body: { control_id: controlId, duration } }) + +export const getWisdomMediation = (profile?: ProfileScope): Promise => + request('/api/wisdom/mediation', profile) + +export const resolveWisdomConsent = ( + interactionId: string, + sessionId: string, + action: WisdomConsentAction, + profile?: ProfileScope +): Promise => + request('/api/wisdom/consent', profile, { + method: 'POST', + body: { interaction_id: interactionId, session_id: sessionId, action } + }) + +export const setupWisdom = (profile?: ProfileScope): Promise => + request('/api/wisdom/setup', profile, { method: 'POST', body: { accept_disclosure: true } }) + +export const scanWisdom = (skill?: string, profile?: ProfileScope): Promise => + request('/api/wisdom/scan', profile, { method: 'POST', body: { skill } }) + +export const getWisdomCandidates = (profile?: ProfileScope): Promise<{ candidates: WisdomCandidate[] }> => + request('/api/wisdom/candidates', profile) + +export const getWisdomEvents = ( + sessionId: string, + profile?: ProfileScope +): Promise<{ events: WisdomCandidateEvent[] }> => + request(`/api/wisdom/events?session_id=${encodeURIComponent(sessionId)}`, profile) + +export const getWisdomDiscovery = (profile?: ProfileScope): Promise => + request('/api/wisdom/discovery', profile) + +export const getWisdomDrafts = (profile?: ProfileScope): Promise<{ drafts: WisdomDraft[] }> => + request('/api/wisdom/drafts', profile) + +export const getWisdomSkill = (skillId: string, profile?: ProfileScope): Promise => + request(`/api/wisdom/skills/${encodeURIComponent(skillId)}`, profile) + +export const getWisdomVersion = ( + skillId: string, + version: number, + profile?: ProfileScope +): Promise => + request(`/api/wisdom/skills/${encodeURIComponent(skillId)}/versions/${version}`, profile) + +export const getWisdomVersionContent = ( + skillId: string, + version: number, + profile?: ProfileScope +): Promise => + request(`/api/wisdom/skills/${encodeURIComponent(skillId)}/versions/${version}/content`, profile) + +export const getWisdomInstallations = (profile?: ProfileScope): Promise => + request('/api/wisdom/installations', profile) + +export const checkWisdom = (profile?: ProfileScope, applyAutomatic = true): Promise => + request('/api/wisdom/check', profile, { method: 'POST', body: { apply_automatic: applyAutomatic } }) + +export const planWisdomInstall = ( + reference: string, + profile?: ProfileScope, + updateMode?: WisdomUpdateMode +): Promise => + request('/api/wisdom/install/plan', profile, { + method: 'POST', + body: { reference, update_mode: updateMode } + }) + +export const applyWisdomInstall = ( + receipt: string, + acceptPartial: boolean, + profile?: ProfileScope +): Promise> => + request('/api/wisdom/install/apply', profile, { + method: 'POST', + body: { accept_partial: acceptPartial, receipt } + }) + +export const planWisdomUpdate = (skillId: string, profile?: ProfileScope): Promise => + request('/api/wisdom/update/plan', profile, { method: 'POST', body: { skill_id: skillId } }) + +export const applyWisdomUpdate = ( + receipt: string, + confirmations: { acceptPartial: boolean; acceptSensitive: boolean; preserveModified: boolean }, + profile?: ProfileScope +): Promise> => + request('/api/wisdom/update/apply', profile, { + method: 'POST', + body: { + accept_partial: confirmations.acceptPartial, + accept_sensitive: confirmations.acceptSensitive, + preserve_modified: confirmations.preserveModified, + receipt + } + }) + +export const uninstallWisdomSkill = (skillId: string, profile?: ProfileScope): Promise> => + request('/api/wisdom/uninstall', profile, { method: 'POST', body: { skill_id: skillId } }) + +export const acknowledgeWisdomNotifications = (profile?: ProfileScope): Promise<{ events: WisdomNotification[] }> => + request('/api/wisdom/notifications', profile, { method: 'POST', body: { mark_seen: true } }) + +export const suggestWisdomSkill = ( + skill: string, + profile?: ProfileScope, + approval?: { description: string; systemSpecification: Record }, + localSkillId?: string +): Promise => + request('/api/wisdom/suggest', profile, { + method: 'POST', + body: { + skill, + local_skill_id: localSkillId, + description: approval?.description, + system_specification: approval?.systemSpecification + } + }) + +export const reviewWisdomDraft = ( + draftId: string, + acknowledge: boolean, + profile?: ProfileScope +): Promise => + request('/api/wisdom/review', profile, { + method: 'POST', + body: { acknowledge, draft_id: draftId } + }) + +export const saveWisdomPreparedDraft = ( + draftId: string, + authorDescription: string, + files: WisdomEditedFile[], + profile?: ProfileScope +): Promise => + request('/api/wisdom/prepared/save', profile, { + method: 'POST', + body: { author_description: authorDescription, draft_id: draftId, files } + }) + +export const dismissWisdomCandidate = ( + localSkillId: string, + contentHash: string, + profile?: ProfileScope +): Promise<{ dismissed: true }> => + request('/api/wisdom/candidates/dismiss', profile, { + method: 'POST', + body: { content_hash: contentHash, local_skill_id: localSkillId } + }) + +export const deferWisdomCandidate = ( + eventId: string, + profile?: ProfileScope +): Promise<{ event_id: string; state: 'deferred' }> => + request('/api/wisdom/candidates/defer', profile, { + method: 'POST', + body: { event_id: eventId } + }) + +export const prepareWisdomCandidate = (eventId: string, profile?: ProfileScope): Promise => + request('/api/wisdom/candidates/prepare', profile, { + method: 'POST', + body: { event_id: eventId } + }) + +export const approveWisdomCandidate = (eventId: string, profile?: ProfileScope): Promise> => + request('/api/wisdom/candidates/approve', profile, { + method: 'POST', + body: { event_id: eventId } + }) + +export const reviseWisdomDraft = ( + draftId: string, + authorDescription: string, + files: WisdomEditedFile[], + hashes: WisdomDraftReview['hashes'], + profile?: ProfileScope +): Promise => + request('/api/wisdom/revise', profile, { + method: 'POST', + body: { + draft_id: draftId, + author_description: authorDescription, + files, + expected_content_hash: hashes.content, + expected_author_description_hash: hashes.author_description, + expected_package_manifest_hash: hashes.package_manifest + } + }) + +export const decideWisdomDraft = ( + draftId: string, + decision: 'approve' | 'decline', + profile?: ProfileScope +): Promise> => + request(`/api/wisdom/${decision}`, profile, { method: 'POST', body: { draft_id: draftId } }) diff --git a/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.test.tsx b/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.test.tsx index 518090d96c..1fa915b9d9 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.test.tsx +++ b/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.test.tsx @@ -157,6 +157,32 @@ describe('useSlashCompletions', () => { expect(commandsOf(await completions(api, 'research'))).toEqual(['/research-paper-writing', '/research']) }) + it('shows backend documentation for wisdom subcommands', async () => { + const request = vi.fn().mockImplementation((method: string) => + Promise.resolve( + method === 'commands.catalog' + ? CATALOG + : { + replace_from: 8, + items: [ + { + text: 'installed', + display: 'installed', + meta: 'List and manage skills installed on this device' + } + ] + } + ) + ) + + const api = harness({ request } as unknown as HermesGateway) + const [installed] = await completions(api, 'wisdom ') + + expect(installed?.label).toBe('installed') + expect(installed?.description).toBe('List and manage skills installed on this device') + expect((installed?.metadata as { command?: string })?.command).toBe('/wisdom installed') + }) + it('keeps a registry command in Commands even when the desktop table has no row', async () => { const request = vi.fn().mockImplementation((method: string) => Promise.resolve( diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index 1b95bc769a..c2786691aa 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -18,7 +18,7 @@ import { PromptOverlays } from '@/components/prompt-overlays' import { Button } from '@/components/ui/button' import { ErrorState } from '@/components/ui/error-state' import { TitleMenuTrigger } from '@/components/ui/title-menu-trigger' -import { type HermesGateway } from '@/hermes' +import { type HermesGateway, type ProfileScope } from '@/hermes' import { useI18n } from '@/i18n' import type { ChatMessage } from '@/lib/chat-messages' import { NEW_SESSION_TITLE, quickModelOptions, sessionTitle } from '@/lib/chat-runtime' @@ -28,6 +28,7 @@ import { useStoreSelector } from '@/lib/use-session-slice' import { cn } from '@/lib/utils' import { migrateSessionDraft } from '@/store/composer' import { migrateQueuedPrompts, parkQueuedPrompts } from '@/store/composer-queue' +import { activeGatewayConnectionId } from '@/store/gateway' import { $introSplash } from '@/store/intro-splash' import { $pinnedSessionIds } from '@/store/layout' import { $petActive } from '@/store/pet' @@ -428,6 +429,13 @@ const ChatViewContent = memo(function ChatViewContent({ const awaitingResponse = useStore(view.$awaitingResponse) const busy = useStore(view.$busy) const activeGatewayProfile = useStore($activeGatewayProfile) + const wisdomConnectionId = activeGatewayConnectionId() + + const wisdomProfile = useMemo( + () => ({ connectionId: wisdomConnectionId, profile: activeGatewayProfile }), + [activeGatewayProfile, wisdomConnectionId] + ) + const contextSuggestions = useStore($contextSuggestions) // Per-session (SessionView) reads — a tile IS its session, so these come // from the view slice, not the global atoms (which track the primary only). @@ -697,6 +705,7 @@ const ChatViewContent = memo(function ChatViewContent({ onRestoreToMessage={onRestoreToMessage} sessionId={activeSessionId} sessionKey={threadKey} + wisdomProfile={wisdomProfile} /> {resumeExhausted && routedSessionId && (
diff --git a/apps/desktop/src/app/skills/collective-tab.test.tsx b/apps/desktop/src/app/skills/collective-tab.test.tsx new file mode 100644 index 0000000000..53f021c33c --- /dev/null +++ b/apps/desktop/src/app/skills/collective-tab.test.tsx @@ -0,0 +1,610 @@ +// @vitest-environment jsdom +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { MemoryRouter } from 'react-router' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +import type * as HermesApi from '@/hermes' + +const getWisdomStatus = vi.fn() +const getWisdomDiscovery = vi.fn() +const getWisdomCandidates = vi.fn() +const getWisdomDrafts = vi.fn() +const getWisdomSkill = vi.fn() +const getWisdomInstallations = vi.fn() +const getWisdomVersionContent = vi.fn() +const suggestWisdomSkill = vi.fn() +const reviewWisdomDraft = vi.fn() +const reviewWisdomPublication = vi.fn() +const saveWisdomPreparedDraft = vi.fn() +const submitWisdomPublication = vi.fn() +const reviseWisdomDraft = vi.fn() +const decideWisdomDraft = vi.fn() +const planWisdomInstall = vi.fn() +const applyWisdomInstall = vi.fn() +const planWisdomUpdate = vi.fn() +const applyWisdomUpdate = vi.fn() +const checkWisdom = vi.fn() +const setupWisdom = vi.fn() +const getActionStatus = vi.fn() + +vi.mock('@/hermes', async importOriginal => ({ + ...(await importOriginal()), + decideWisdomDraft, + getWisdomCandidates, + getWisdomDiscovery, + getWisdomDrafts, + getWisdomSkill, + getWisdomInstallations, + getWisdomVersionContent, + getWisdomStatus, + reviewWisdomDraft, + reviewWisdomPublication, + saveWisdomPreparedDraft, + submitWisdomPublication, + reviseWisdomDraft, + suggestWisdomSkill, + planWisdomInstall, + applyWisdomInstall, + planWisdomUpdate, + applyWisdomUpdate, + checkWisdom, + setupWisdom, + getActionStatus +})) + +vi.mock('@/store/notifications', () => ({ notifyError: vi.fn() })) + +const scope = { connectionId: 'gateway-a', profile: 'research' } +const originalScrollIntoView = Element.prototype.scrollIntoView + +beforeAll(() => { + Element.prototype.scrollIntoView = vi.fn() +}) + +afterAll(() => { + Element.prototype.scrollIntoView = originalScrollIntoView +}) + +beforeEach(() => { + checkWisdom.mockResolvedValue({ installations: [] }) +}) + +const systemSpecification = { + hermes: { minimum_version: '0.20.5' }, + platforms: ['macOS'], + architectures: ['arm64'], + model: { capabilities: [], minimum_context_window: null }, + tools: [], + plugins: [], + credentials: [], + connections: [], + filesystem: { read: [], write: [] }, + network: { destinations: [] }, + runtime: { shell: false, browser: false, code: false, sandbox: true }, + hardware: [], + known_limitations: [] +} + +async function renderTab(initialEntry = '/skills?tab=collective') { + const { CollectiveTab } = await import('./collective-tab') + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + + return render( + + + + + + ) +} + +afterEach(() => vi.clearAllMocks()) + +function mockInstallations() { + getWisdomInstallations.mockResolvedValue({ installations: [], notifications: [] }) +} + +describe('CollectiveTab', () => { + it('keeps collective reads disabled until disclosure setup is accepted', async () => { + getWisdomStatus + .mockResolvedValueOnce({ configured: false, verified_org_id: null }) + .mockResolvedValueOnce({ configured: true, verified_org_id: 'org-1' }) + getWisdomDiscovery.mockResolvedValue({ next_cursor: null, skills: [] }) + getWisdomCandidates.mockResolvedValue({ candidates: [] }) + getWisdomDrafts.mockResolvedValue({ drafts: [] }) + mockInstallations() + setupWisdom.mockResolvedValue({ ok: true, name: 'wisdom-setup', pid: 1 }) + getActionStatus.mockResolvedValue({ + name: 'wisdom-setup', + running: false, + exit_code: 0, + pid: 1, + lines: [] + }) + + await renderTab() + expect(await screen.findByText(/Candidate qualification stays on this profile/)).toBeTruthy() + expect(getWisdomDiscovery).not.toHaveBeenCalled() + fireEvent.click(screen.getByRole('button', { name: /set up this profile/ })) + + await waitFor(() => expect(setupWisdom).toHaveBeenCalledWith(scope)) + expect(getActionStatus).toHaveBeenCalledWith('wisdom-setup', 80, scope) + }, 30_000) + + it('scopes reads to the selected connection/profile and renders hostile text as text', async () => { + mockInstallations() + getWisdomStatus.mockResolvedValue({ configured: true, verified_org_id: 'org-1' }) + getWisdomCandidates.mockResolvedValue({ candidates: [] }) + getWisdomDrafts.mockResolvedValue({ drafts: [] }) + getWisdomDiscovery.mockResolvedValue({ + next_cursor: null, + skills: [ + { + id: 'skill-1', + slug: '', + author_description: '', + install_count: 0, + latest_version: 1, + state: 'active' + } + ] + }) + + await renderTab() + + expect(await screen.findByText('')).toBeTruthy() + expect(globalThis.document.querySelector('script')).toBeNull() + expect(getWisdomDiscovery).toHaveBeenCalledWith(scope) + expect(getWisdomCandidates).toHaveBeenCalledWith(scope) + }) + + it('refreshes shared-skill discovery on demand', async () => { + mockInstallations() + getWisdomStatus.mockResolvedValue({ configured: true, verified_org_id: 'org-1' }) + getWisdomCandidates.mockResolvedValue({ candidates: [] }) + getWisdomDrafts.mockResolvedValue({ drafts: [] }) + getWisdomDiscovery + .mockResolvedValueOnce({ + next_cursor: null, + skills: [ + { + id: 'skill-1', + slug: 'existing-skill', + author_description: 'Already visible', + install_count: 0, + latest_version: 1, + state: 'active' + } + ] + }) + .mockResolvedValue({ + next_cursor: null, + skills: [ + { + id: 'skill-1', + slug: 'existing-skill', + author_description: 'Already visible', + install_count: 0, + latest_version: 1, + state: 'active' + }, + { + id: 'skill-2', + slug: 'newly-shared-skill', + author_description: 'Published after the screen opened', + install_count: 0, + latest_version: 1, + state: 'active' + } + ] + }) + + await renderTab() + expect(await screen.findByText('existing-skill')).toBeTruthy() + expect(screen.queryByText('newly-shared-skill')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Refresh shared skills' })) + + expect(await screen.findByText('newly-shared-skill')).toBeTruthy() + expect(getWisdomDiscovery).toHaveBeenCalledTimes(2) + }) + + it('refreshes registry discovery while checking managed updates', async () => { + mockInstallations() + checkWisdom.mockResolvedValue({ installations: [] }) + getWisdomStatus.mockResolvedValue({ configured: true, verified_org_id: 'org-1' }) + getWisdomCandidates.mockResolvedValue({ candidates: [] }) + getWisdomDrafts.mockResolvedValue({ drafts: [] }) + getWisdomDiscovery.mockResolvedValueOnce({ next_cursor: null, skills: [] }).mockResolvedValue({ + next_cursor: null, + skills: [ + { + id: 'skill-2', + slug: 'discovered-during-update-check', + author_description: 'Newly shared', + install_count: 0, + latest_version: 1, + state: 'active' + } + ] + }) + + await renderTab() + fireEvent.click(await screen.findByRole('button', { name: 'Check updates' })) + + expect(await screen.findByText('discovered-during-update-check')).toBeTruthy() + expect(checkWisdom).toHaveBeenCalledWith(scope) + expect(getWisdomDiscovery).toHaveBeenCalledTimes(2) + }) + + it('checks on load and marks installed skills with a pending target version', async () => { + getWisdomStatus.mockResolvedValue({ configured: true, verified_org_id: 'org-1' }) + getWisdomCandidates.mockResolvedValue({ candidates: [] }) + getWisdomDrafts.mockResolvedValue({ drafts: [] }) + getWisdomDiscovery.mockResolvedValue({ + next_cursor: null, + skills: [ + { + id: 'skill-1', + slug: 'gateway-pull-canary', + author_description: 'Managed canary', + install_count: 1, + latest_version: 2, + state: 'active' + } + ] + }) + getWisdomInstallations.mockResolvedValue({ + installations: [ + { + skill_id: 'skill-1', + slug: 'gateway-pull-canary', + version: 1, + update_mode: 'MANUAL', + state: 'active', + target_path: '/managed/gateway-pull-canary' + } + ], + notifications: [] + }) + checkWisdom.mockResolvedValue({ + installations: [ + { + skill_id: 'skill-1', + state: 'update_available', + plan: { skill_id: 'skill-1', version: 2, receipt: 'wup_1' } + } + ] + }) + getWisdomSkill.mockResolvedValue({ + skill: { id: 'skill-1', slug: 'gateway-pull-canary' }, + versions: [{ version: 2 }] + }) + getWisdomVersionContent.mockResolvedValue({ commit: 'sha256:commit', content_hash: 'sha256:content', files: [] }) + + await renderTab() + + await waitFor(() => expect(checkWisdom).toHaveBeenCalledWith(scope)) + expect(await screen.findByText('v2 update available')).toBeTruthy() + expect(screen.getByRole('button', { name: 'Check updates (1)' })).toBeTruthy() + + fireEvent.click(screen.getByRole('button', { name: /gateway-pull-canary/ })) + expect(await screen.findByRole('button', { name: 'Review update' })).toBeTruthy() + }) + + it.each(['open', 'moderated'] as const)('reviews and rescans locally before one final %s submission', async publicationMode => { + mockInstallations() + getWisdomStatus.mockResolvedValue({ configured: true, verified_org_id: 'org-1' }) + getWisdomDiscovery.mockResolvedValue({ next_cursor: null, skills: [] }) + getWisdomCandidates.mockResolvedValue({ + candidates: [ + { + local_skill_id: 'local-1', + name: 'candidate-skill', + eligibility: 'eligible', + reason: null, + qualification: 'manual_selection', + contribution_state: 'new' + } + ] + }) + getWisdomDrafts.mockResolvedValue({ drafts: [] }) + suggestWisdomSkill.mockResolvedValueOnce({ + network_submission: false, + local_draft_id: 'local:draft', + overlay_path: '/private/overlay', + drafted_description: 'Drafted copy', + system_specification: systemSpecification, + next_step: 'review' + }) + const manifest = JSON.stringify({ schema_version: 1, name: 'candidate-skill', requirements: systemSpecification }) + + const initialReview = { + draft: { id: 'local:draft', slug: 'candidate-skill', state: 'prepared', authorDescription: 'Drafted copy' }, + publication_mode: publicationMode, + effective_policy: {}, + files: [ + { path: 'SKILL.md', mode: 'file', hash: 'sha256:skill', content_utf8: '# Candidate\n' }, + { path: 'skill.manifest.json', mode: 'file', hash: 'sha256:manifest', content_utf8: manifest } + ], + hashes: { content: 'sha256:content', author_description: 'sha256:description', package_manifest: 'sha256:manifest' }, + receipt: null + } + + const rescannedReview = { + ...initialReview, + draft: { ...initialReview.draft, authorDescription: 'Approved owner copy' }, + hashes: { ...initialReview.hashes, author_description: 'sha256:revised' } + } + + reviewWisdomPublication.mockResolvedValueOnce(initialReview).mockResolvedValueOnce(rescannedReview) + saveWisdomPreparedDraft.mockResolvedValue({ local_draft_id: 'local:draft' }) + submitWisdomPublication.mockResolvedValue({ + draft_id: 'draft-1', + publication_state: publicationMode === 'open' ? 'published' : 'pending_moderation', + portal_url: 'https://portal.example/skill/draft-1' + }) + + await renderTab() + fireEvent.click(await screen.findByText('View all local skills (1)')) + fireEvent.click(await screen.findByRole('button', { name: 'Start contribution' })) + const description = await screen.findByLabelText('Owner-authored description') + fireEvent.change(description, { target: { value: 'Approved owner copy' } }) + const action = publicationMode === 'open' ? 'Publish to team' : 'Submit for approval' + expect(screen.getByRole('button', { name: action })).toHaveProperty('disabled', true) + expect(submitWisdomPublication).not.toHaveBeenCalled() + fireEvent.click(screen.getByRole('button', { name: 'Save changes & rescan' })) + await waitFor(() => expect(screen.getByRole('button', { name: action })).toHaveProperty('disabled', false)) + expect(saveWisdomPreparedDraft).toHaveBeenCalledWith( + 'local:draft', 'Approved owner copy', + initialReview.files.map(({ path, content_utf8 }) => ({ path, content_utf8 })), scope + ) + fireEvent.click(screen.getByRole('button', { name: action })) + await waitFor(() => expect(submitWisdomPublication).toHaveBeenCalledExactlyOnceWith(rescannedReview, scope, undefined)) + expect(suggestWisdomSkill).toHaveBeenCalledTimes(1) + expect(suggestWisdomSkill.mock.calls[0][3]).toBe('local-1') + expect((await screen.findByRole('link', { name: 'View in Portal' })).getAttribute('href')).toBe( + 'https://portal.example/skill/draft-1' + ) + }) + + it('separates qualified suggestions, manual inventory, and submissions waiting on collective approval', async () => { + mockInstallations() + getWisdomStatus.mockResolvedValue({ configured: true, verified_org_id: 'org-1' }) + getWisdomDiscovery.mockResolvedValue({ next_cursor: null, skills: [] }) + getWisdomCandidates.mockResolvedValue({ + candidates: [ + { + local_skill_id: 'qualified-1', + name: 'qualified-skill', + editorial_name: 'Qualified Skill', + editorial_description: 'A useful skill ready for team review.', + eligibility: 'eligible', + reason: null, + qualification: 'high_usage', + contribution_state: 'new' + }, + { + local_skill_id: 'manual-1', + name: 'manual-only-skill', + eligibility: 'eligible', + reason: null, + qualification: 'manual_selection', + contribution_state: 'new' + } + ] + }) + getWisdomDrafts.mockResolvedValue({ + drafts: [ + { id: 'draft-ready', slug: 'needs-review', state: 'ready' }, + { id: 'draft-pending', slug: 'waiting-on-admin', state: 'pending_moderation' }, + { id: 'draft-published', slug: 'already-shared', state: 'published' }, + { id: 'draft-invalid', slug: 'old-revision', state: 'invalidated' } + ] + }) + + await renderTab() + + expect(await screen.findByText('needs-review')).toBeTruthy() + expect(screen.getByText('Ready for your review')).toBeTruthy() + expect(screen.getByText('waiting-on-admin')).toBeTruthy() + expect(screen.getByText('Waiting for collective administrator approval')).toBeTruthy() + expect( + screen.getByText('Drafts awaiting your review and submissions waiting for collective approval.') + ).toBeTruthy() + expect(screen.getByText('1 qualified suggestion')).toBeTruthy() + expect(screen.getByText('Qualified Skill')).toBeTruthy() + expect(screen.getByText('A useful skill ready for team review.')).toBeTruthy() + expect(screen.getAllByRole('button', { name: 'Start contribution' })).toHaveLength(1) + expect(screen.getByText('View all local skills (1)')).toBeTruthy() + expect(screen.queryByText('already-shared')).toBeNull() + expect(screen.queryByText('old-revision')).toBeNull() + }) + + it('edits owner copy and Markdown through a rescanned successor before approval', async () => { + mockInstallations() + getWisdomStatus.mockResolvedValue({ configured: true, verified_org_id: 'org-1' }) + getWisdomDiscovery.mockResolvedValue({ next_cursor: null, skills: [] }) + getWisdomCandidates.mockResolvedValue({ candidates: [] }) + getWisdomDrafts.mockResolvedValue({ + drafts: [{ id: 'draft-1', slug: 'editable-skill', state: 'ready', authorDescription: 'Original copy' }] + }) + const manifest = `${JSON.stringify({ schema_version: 1, name: 'editable-skill', requirements: systemSpecification })}\n` + + const initialReview = { + draft: { + id: 'draft-1', + slug: 'editable-skill', + state: 'ready', + authorDescription: 'Original copy', + scanVerdict: 'PASS' + }, + effective_policy: {}, + publication_mode: 'moderated', + files: [ + { path: 'SKILL.md', mode: 'file', hash: 'sha256:skill', content_utf8: '# Original\n' }, + { path: 'skill.manifest.json', mode: 'file', hash: 'sha256:manifest', content_utf8: manifest } + ], + hashes: { + content: 'sha256:content', + author_description: 'sha256:description', + package_manifest: 'sha256:manifest' + }, + receipt: null + } + + const revisedReview = { + ...initialReview, + draft: { ...initialReview.draft, id: 'draft-2', authorDescription: 'Revised copy' }, + files: [{ ...initialReview.files[0], content_utf8: '# Revised\n' }, initialReview.files[1]], + hashes: { ...initialReview.hashes, content: 'sha256:revised' } + } + + reviewWisdomPublication.mockResolvedValueOnce(initialReview).mockResolvedValueOnce(revisedReview) + reviseWisdomDraft.mockResolvedValue({ draft: revisedReview.draft, local_scan: {}, notice: 'rescanned' }) + + await renderTab() + fireEvent.click(await screen.findByRole('button', { name: /editable-skill.*View details/ })) + fireEvent.change(await screen.findByLabelText('Owner-authored description'), { + target: { value: 'Revised copy' } + }) + fireEvent.change(screen.getByLabelText('Edit SKILL.md'), { target: { value: '# Revised\n' } }) + expect(screen.getByRole('button', { name: 'Submit for approval' })).toHaveProperty('disabled', true) + fireEvent.click(screen.getByRole('button', { name: 'Save changes & rescan' })) + + await waitFor(() => expect(reviseWisdomDraft).toHaveBeenCalledTimes(1)) + expect(reviseWisdomDraft).toHaveBeenCalledWith( + 'draft-1', + 'Revised copy', + [ + { path: 'SKILL.md', content_utf8: '# Revised\n' }, + { path: 'skill.manifest.json', content_utf8: manifest } + ], + initialReview.hashes, + scope + ) + await waitFor(() => + expect(screen.getByRole('button', { name: 'Submit for approval' })).toHaveProperty('disabled', false) + ) + }) + + it('requires a verified plan before applying a managed install', async () => { + mockInstallations() + getWisdomStatus.mockResolvedValue({ configured: true, verified_org_id: 'org-1' }) + getWisdomCandidates.mockResolvedValue({ candidates: [] }) + getWisdomDrafts.mockResolvedValue({ drafts: [] }) + getWisdomDiscovery.mockResolvedValue({ + next_cursor: null, + skills: [ + { + id: 'skill-1', + slug: 'managed-skill', + author_description: 'Does work', + install_count: 0, + latest_version: 2, + state: 'active' + } + ] + }) + getWisdomSkill.mockResolvedValue({ skill: { id: 'skill-1', slug: 'managed-skill' }, versions: [{ version: 2 }] }) + getWisdomVersionContent.mockResolvedValue({ commit: 'sha256:commit', content_hash: 'sha256:content', files: [] }) + planWisdomInstall + .mockResolvedValueOnce({ + receipt: 'wip_1', + skill_id: 'skill-1', + version: 2, + compatibility: { outcome: 'compatible' } + }) + .mockResolvedValueOnce({ + receipt: 'wip_auto', + skill_id: 'skill-1', + version: 2, + update_mode: 'AUTO_WITH_NOTICE', + compatibility: { outcome: 'compatible' } + }) + applyWisdomInstall.mockResolvedValue({ installed: true }) + + await renderTab() + fireEvent.click(await screen.findByRole('button', { name: /managed-skill/ })) + fireEvent.click(await screen.findByRole('button', { name: 'Install…' })) + const preview = await screen.findByRole('region', { name: 'Confirm install' }) + const dialog = screen.getByRole('dialog', { name: 'Verified managed action plan' }) + + expect(preview.textContent).toContain('wip_1') + fireEvent.click(within(dialog).getByRole('combobox', { name: 'Future updates' })) + fireEvent.click(await screen.findByRole('option', { name: 'Automatic with notice' })) + await waitFor(() => expect(planWisdomInstall).toHaveBeenLastCalledWith('skill-1', scope, 'AUTO_WITH_NOTICE')) + expect(preview.textContent).toContain('wip_auto') + + const confirm = screen.getByRole('button', { name: 'Confirm install' }) + expect(preview.contains(confirm)).toBe(false) + fireEvent.click(confirm) + await waitFor(() => expect(applyWisdomInstall).toHaveBeenCalledWith('wip_auto', false, scope)) + }) + + it('opens the verified update plan from a notification deep link', async () => { + mockInstallations() + getWisdomStatus.mockResolvedValue({ configured: true, verified_org_id: 'org-1' }) + getWisdomCandidates.mockResolvedValue({ candidates: [] }) + getWisdomDrafts.mockResolvedValue({ drafts: [] }) + getWisdomDiscovery.mockResolvedValue({ next_cursor: null, skills: [] }) + getWisdomSkill.mockResolvedValue({ + skill: { id: 'skill-1', slug: 'managed-skill' }, + versions: [{ version: 3 }] + }) + getWisdomVersionContent.mockResolvedValue({ commit: 'sha256:commit', content_hash: 'sha256:content', files: [] }) + planWisdomUpdate.mockResolvedValue({ + receipt: 'wup_notification', + skill_id: 'skill-1', + version: 3, + compatibility: { outcome: 'compatible' } + }) + + await renderTab('/skills?tab=collective&wisdomAction=update&wisdomSkillId=skill-1') + + await waitFor(() => expect(planWisdomUpdate).toHaveBeenCalledWith('skill-1', scope)) + expect((await screen.findByRole('region', { name: 'Confirm update' })).textContent).toContain('wup_notification') + expect(applyWisdomUpdate).not.toHaveBeenCalled() + }) + + it('plans a pasted Portal install link before allowing an install', async () => { + const portalLink = + 'http://127.0.0.1:3111/orgs/wisdom-local/wisdom/skills/0a192cc7-486e-426d-a6b4-493119c1c011?version=1' + + mockInstallations() + getWisdomStatus.mockResolvedValue({ configured: true, verified_org_id: 'org-1' }) + getWisdomCandidates.mockResolvedValue({ candidates: [] }) + getWisdomDrafts.mockResolvedValue({ drafts: [] }) + getWisdomDiscovery.mockResolvedValue({ next_cursor: null, skills: [] }) + getWisdomSkill.mockResolvedValue({ + skill: { id: '0a192cc7-486e-426d-a6b4-493119c1c011', slug: 'gateway-pull-canary' }, + versions: [] + }) + planWisdomInstall.mockResolvedValue({ + receipt: 'wip_from_link', + skill_id: '0a192cc7-486e-426d-a6b4-493119c1c011', + version: 1, + update_mode: 'AUTO_WITH_NOTICE', + compatibility: { outcome: 'compatible' } + }) + applyWisdomInstall.mockResolvedValue({ installed: true }) + + await renderTab() + fireEvent.change(await screen.findByLabelText('Install from link or skill ID'), { + target: { value: portalLink } + }) + fireEvent.click(screen.getByRole('combobox', { name: 'Future updates' })) + fireEvent.click(await screen.findByRole('option', { name: 'Automatic with notice' })) + fireEvent.click(screen.getByRole('button', { name: 'Review install' })) + + await waitFor(() => expect(planWisdomInstall).toHaveBeenCalledWith(portalLink, scope, 'AUTO_WITH_NOTICE')) + expect(applyWisdomInstall).not.toHaveBeenCalled() + expect(await screen.findByText(/wip_from_link/)).toBeTruthy() + const dialog = screen.getByRole('dialog', { name: 'Verified managed action plan' }) + expect(within(dialog).getByRole('combobox', { name: 'Future updates' }).textContent).toContain( + 'Automatic with notice' + ) + fireEvent.click(screen.getByRole('button', { name: 'Confirm install' })) + await waitFor(() => expect(applyWisdomInstall).toHaveBeenCalledWith('wip_from_link', false, scope)) + }) +}) diff --git a/apps/desktop/src/app/skills/collective-tab.tsx b/apps/desktop/src/app/skills/collective-tab.tsx new file mode 100644 index 0000000000..b9ace5fd25 --- /dev/null +++ b/apps/desktop/src/app/skills/collective-tab.tsx @@ -0,0 +1,1017 @@ +import { useQuery } from '@tanstack/react-query' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useLocation, useNavigate } from 'react-router' + +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { WisdomCheckBadge, WisdomReviewTables } from '@/components/wisdom-checks' +import { WisdomMediationCard } from '@/components/wisdom-mediation-card' +import { WisdomNotificationSettings } from '@/components/wisdom-notification-settings' +import { WisdomNotificationsCard } from '@/components/wisdom-notifications-card' +import { WisdomPublicationReview } from '@/components/wisdom-publication-review' +import { WisdomSyncStatus } from '@/components/wisdom-sync-status' +import { + acknowledgeWisdomNotifications, + applyWisdomInstall, + applyWisdomUpdate, + checkWisdom, + getActionStatus, + getWisdomCandidates, + getWisdomDiscovery, + getWisdomDrafts, + getWisdomInstallations, + getWisdomSkill, + getWisdomStatus, + getWisdomVersionContent, + planWisdomInstall, + planWisdomUpdate, + type ProfileScope, + profileScopeKey, + scanWisdom, + setupWisdom, + suggestWisdomSkill, + uninstallWisdomSkill, + type WisdomActionPlan, + type WisdomCandidate, + type WisdomCheckResult, + type WisdomReviewCheck, + type WisdomUpdateMode +} from '@/hermes' +import { useI18n } from '@/i18n' +import { cn } from '@/lib/utils' +import { notifyError } from '@/store/notifications' + +import { DetailColumn, ListColumn, ListStrip, MasterDetail } from '../master-detail' + +const TERMINAL_DRAFT_STATES = new Set(['published', 'declined', 'invalidated', 'rejected']) +const UPDATE_CHECK_INTERVAL_MS = 5 * 60 * 1000 + +function asRecord(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : {} +} + +const candidateDisplayName = (candidate: WisdomCandidate): string => candidate.editorial_name?.trim() || candidate.name + +const candidateDisplayDescription = (candidate: WisdomCandidate): string => + candidate.editorial_description?.trim() || '' + +async function waitForWisdomAction(name: string, profile: ProfileScope): Promise { + for (let attempt = 0; attempt < 1200; attempt += 1) { + const status = await getActionStatus(name, 80, profile) + + if (!status.running) { + if (status.exit_code !== 0) { + throw new Error(status.lines.at(-1) || `Collective Wisdom action failed (${status.exit_code ?? 'unknown'})`) + } + + return + } + + await new Promise(resolve => setTimeout(resolve, 500)) + } + + throw new Error('Collective Wisdom action timed out') +} + +export function CollectiveTab({ profile, query }: { profile: ProfileScope; query: string }) { + const { t } = useI18n() + const copy = t.skills.collective + const location = useLocation() + const navigate = useNavigate() + const scope = profileScopeKey(profile) + const [selectedId, setSelectedId] = useState(null) + + const [publicationDraftId, setPublicationDraftId] = useState(null) + const [busy, setBusy] = useState(null) + const [showManualCandidates, setShowManualCandidates] = useState(false) + const [installReference, setInstallReference] = useState('') + const [installUpdateMode, setInstallUpdateMode] = useState<'' | WisdomUpdateMode>('') + + const [actionPlan, setActionPlan] = useState< + null | (WisdomActionPlan & { action: 'install' | 'uninstall' | 'update' }) + >(null) + + const [actionPlanReference, setActionPlanReference] = useState(null) + + const [acceptSensitive, setAcceptSensitive] = useState(false) + const [acceptPartial, setAcceptPartial] = useState(false) + const [preserveModified, setPreserveModified] = useState(false) + + useEffect(() => { + setSelectedId(null) + setPublicationDraftId(null) + setShowManualCandidates(false) + setInstallReference('') + setInstallUpdateMode('') + setActionPlan(null) + setActionPlanReference(null) + setAcceptSensitive(false) + setAcceptPartial(false) + setPreserveModified(false) + setBusy(null) + }, [scope]) + + const status = useQuery({ + queryKey: ['wisdom-status', scope], + queryFn: () => getWisdomStatus(profile), + staleTime: 30_000 + }) + + const discovery = useQuery({ + queryKey: ['wisdom-discovery', scope], + queryFn: () => getWisdomDiscovery(profile), + staleTime: 30_000, + enabled: status.data?.configured === true + }) + + const candidates = useQuery({ + queryKey: ['wisdom-candidates', scope], + queryFn: () => getWisdomCandidates(profile), + staleTime: 15_000, + enabled: status.data?.configured === true + }) + + const drafts = useQuery({ + queryKey: ['wisdom-drafts', scope], + queryFn: () => getWisdomDrafts(profile), + staleTime: 15_000, + enabled: status.data?.configured === true + }) + + const detail = useQuery({ + queryKey: ['wisdom-detail', scope, selectedId], + queryFn: () => getWisdomSkill(selectedId || '', profile), + enabled: status.data?.configured === true && Boolean(selectedId), + staleTime: 30_000 + }) + + const installations = useQuery({ + queryKey: ['wisdom-installations', scope], + queryFn: () => getWisdomInstallations(profile), + staleTime: 10_000, + enabled: status.data?.configured === true + }) + + const refetchInstallations = installations.refetch + + const updateCheck = useQuery({ + queryKey: ['wisdom-update-check', scope], + queryFn: () => checkWisdom(profile), + enabled: status.data?.configured === true, + staleTime: UPDATE_CHECK_INTERVAL_MS, + refetchInterval: UPDATE_CHECK_INTERVAL_MS, + refetchIntervalInBackground: false, + refetchOnWindowFocus: true + }) + + useEffect(() => { + if (!updateCheck.dataUpdatedAt) { + return + } + + void refetchInstallations().catch(error => notifyError(error, 'Wisdom installation refresh failed')) + }, [refetchInstallations, updateCheck.dataUpdatedAt]) + + const latestSelectedVersion = useMemo( + () => Math.max(0, ...(detail.data?.versions ?? []).map(version => Number(version.version) || 0)), + [detail.data?.versions] + ) + + const content = useQuery({ + queryKey: ['wisdom-content', scope, selectedId, latestSelectedVersion], + queryFn: () => getWisdomVersionContent(selectedId || '', latestSelectedVersion, profile), + enabled: Boolean(selectedId && latestSelectedVersion), + staleTime: 60_000 + }) + + const rows = useMemo(() => { + const needle = query.trim().toLocaleLowerCase() + + return (discovery.data?.skills ?? []).filter( + skill => + !needle || + skill.slug.toLocaleLowerCase().includes(needle) || + (skill.author_description ?? '').toLocaleLowerCase().includes(needle) + ) + }, [discovery.data?.skills, query]) + + const pendingUpdates = useMemo( + () => + new Map( + (updateCheck.data?.installations ?? []) + .filter(item => item.state === 'update_available') + .map(item => [item.skill_id, item] as const) + ), + [updateCheck.data?.installations] + ) + + const activeDrafts = useMemo( + () => (drafts.data?.drafts ?? []).filter(draft => !TERMINAL_DRAFT_STATES.has(draft.state)), + [drafts.data?.drafts] + ) + + const { manualCandidates, qualifiedCandidates } = useMemo(() => { + const all = candidates.data?.candidates ?? [] + + return { + manualCandidates: all.filter(candidate => candidate.qualification === 'manual_selection'), + qualifiedCandidates: all.filter(candidate => candidate.qualification !== 'manual_selection') + } + }, [candidates.data?.candidates]) + + const filterCandidates = useCallback( + (items: WisdomCandidate[]) => { + const needle = query.trim().toLocaleLowerCase() + + return items + .filter(candidate => + !needle + ? true + : [candidate.name, candidateDisplayName(candidate), candidateDisplayDescription(candidate)].some(value => + value.toLocaleLowerCase().includes(needle) + ) + ) + .toSorted((left, right) => candidateDisplayName(left).localeCompare(candidateDisplayName(right))) + }, + [query] + ) + + const visibleQualifiedCandidates = useMemo( + () => filterCandidates(qualifiedCandidates), + [filterCandidates, qualifiedCandidates] + ) + + const visibleManualCandidates = useMemo( + () => filterCandidates(manualCandidates), + [filterCandidates, manualCandidates] + ) + + const refreshContributionData = useCallback(async () => { + await Promise.all([candidates.refetch(), drafts.refetch(), discovery.refetch()]) + }, [candidates, discovery, drafts]) + + const candidateSummary = (candidate: WisdomCandidate): string => { + if (candidate.eligibility !== 'eligible') { + return candidate.reason || copy.localOnly + } + + const qualification = + candidate.qualification === 'manual_selection' + ? copy.localOnly + : candidate.notice_variant === 'first' + ? copy.qualificationFirst(candidate.organization_name) + : copy.qualificationReturning + + return candidate.contribution_state === 'prepared' ? `${qualification} ${copy.savedLocally}` : qualification + } + + const prepare = async (candidate: WisdomCandidate) => { + setBusy(candidate.local_skill_id) + + try { + const result = await suggestWisdomSkill(candidate.name, profile, undefined, candidate.local_skill_id) + setPublicationDraftId('network_submission' in result ? result.local_draft_id : result.draft.id) + } catch (error) { + notifyError(error, 'Collective Wisdom preparation failed') + } finally { + setBusy(null) + } + } + + const openReview = (draftId: string) => setPublicationDraftId(draftId) + + const installed = installations.data?.installations.find( + item => item.skill_id === selectedId && item.state === 'active' + ) + + const selectedUpdate = selectedId ? pendingUpdates.get(selectedId) : undefined + + const planManagedActionForSkill = useCallback( + async (skillId: string, action: 'install' | 'uninstall' | 'update') => { + setSelectedId(skillId) + setBusy(skillId) + + try { + const plan = + action === 'install' + ? await planWisdomInstall(skillId, profile, installUpdateMode || undefined) + : action === 'update' + ? await planWisdomUpdate(skillId, profile) + : { skill_id: skillId, state: 'confirm_uninstall' } + + setActionPlan({ ...plan, action }) + setActionPlanReference(action === 'install' ? skillId : null) + setAcceptSensitive(false) + setAcceptPartial(false) + setPreserveModified(false) + } catch (error) { + notifyError(error, `Wisdom ${action} planning failed`) + } finally { + setBusy(null) + } + }, + [installUpdateMode, profile] + ) + + const planManagedAction = async (action: 'install' | 'uninstall' | 'update') => { + if (selectedId) { + await planManagedActionForSkill(selectedId, action) + } + } + + useEffect(() => { + if (!status.data?.configured) { + return + } + + const params = new URLSearchParams(location.search) + const action = params.get('wisdomAction') + const skillId = params.get('wisdomSkillId') + + if ((action !== 'install' && action !== 'update') || !skillId) { + return + } + + params.delete('wisdomAction') + params.delete('wisdomSkillId') + const search = params.toString() + + navigate( + { hash: location.hash, pathname: location.pathname, search: search ? `?${search}` : '' }, + { replace: true } + ) + void planManagedActionForSkill(skillId, action) + }, [location.hash, location.pathname, location.search, navigate, planManagedActionForSkill, status.data?.configured]) + + const planReferencedInstall = async () => { + const reference = installReference.trim() + + if (!reference) { + return + } + + setBusy('install-reference') + + try { + const plan = await planWisdomInstall(reference, profile, installUpdateMode || undefined) + setSelectedId(plan.skill_id) + setActionPlan({ ...plan, action: 'install' }) + setActionPlanReference(reference) + setAcceptSensitive(false) + setAcceptPartial(false) + setPreserveModified(false) + } catch (error) { + notifyError(error, 'Wisdom install planning failed') + } finally { + setBusy(null) + } + } + + const replanInstallUpdateMode = async (value: string) => { + if (!actionPlan || actionPlan.action !== 'install') { + return + } + + const previousMode = installUpdateMode + const nextMode = value === 'DEFAULT' ? '' : (value as WisdomUpdateMode) + const reference = actionPlanReference || actionPlan.skill_id + + setInstallUpdateMode(nextMode) + setBusy('install-mode') + + try { + const plan = await planWisdomInstall(reference, profile, nextMode || undefined) + setActionPlan({ ...plan, action: 'install' }) + setAcceptSensitive(false) + setAcceptPartial(false) + setPreserveModified(false) + } catch (error) { + setInstallUpdateMode(previousMode) + notifyError(error, 'Wisdom install planning failed') + } finally { + setBusy(null) + } + } + + const applyManagedAction = async () => { + if (!actionPlan) { + return + } + + setBusy(actionPlan.skill_id) + + try { + if (actionPlan.action === 'uninstall') { + await uninstallWisdomSkill(actionPlan.skill_id, profile) + } else if (!actionPlan.receipt) { + throw new Error('Verified action receipt is missing') + } else if (actionPlan.action === 'install') { + await applyWisdomInstall(actionPlan.receipt, acceptPartial, profile) + } else { + await applyWisdomUpdate(actionPlan.receipt, { acceptPartial, acceptSensitive, preserveModified }, profile) + } + + setActionPlan(null) + setActionPlanReference(null) + + if (actionPlan.action === 'install') { + setInstallReference('') + setInstallUpdateMode('') + } + + await Promise.all([installations.refetch(), discovery.refetch(), detail.refetch(), updateCheck.refetch()]) + } catch (error) { + notifyError(error, `Wisdom ${actionPlan.action} failed`) + } finally { + setBusy(null) + } + } + + const setupProfile = async () => { + setBusy('setup') + + try { + const action = await setupWisdom(profile) + await waitForWisdomAction(action.name, profile) + await status.refetch() + await Promise.all([discovery.refetch(), candidates.refetch(), drafts.refetch(), installations.refetch()]) + } catch (error) { + notifyError(error, 'Collective Wisdom setup failed') + } finally { + setBusy(null) + } + } + + if (status.isPending) { + return
{copy.loading}
+ } + + if (status.isError) { + const error = status.error + + return ( +
+ {copy.unavailable} {error instanceof Error ? error.message : ''} +
+ ) + } + + if (!status.data.configured) { + return ( +
+
+
+

{copy.title}

+

{copy.setup}

+
+

{copy.setupDisclosure}

+ {status.data.error && ( +
+ {status.data.error} +
+ )} + +
+
+ ) + } + + if (discovery.isPending || candidates.isPending || drafts.isPending || installations.isPending) { + return
{copy.loading}
+ } + + if (discovery.isError || candidates.isError || drafts.isError || installations.isError) { + const error = discovery.error || candidates.error || drafts.error || installations.error + + return ( +
+ {copy.unavailable} {error instanceof Error ? error.message : ''} +
+ ) + } + + const statusCopy = status.data?.verified_org_id ? `${status.data.verified_org_id} · ${copy.orgWide}` : copy.setup + + return ( +
+
+ +
+
+
{copy.title}
+
{statusCopy}
+
+
+ + + +
+
+
{ + event.preventDefault() + void planReferencedInstall() + }} + > +
+ + setInstallReference(event.target.value)} + placeholder={copy.installReferencePlaceholder} + size="sm" + value={installReference} + /> +

+ {copy.installReferenceHelp} +

+
+
+ + +

+ {copy.updateModeHelp} +

+
+ +
+ + + { + try { + await acknowledgeWisdomNotifications(profile) + await installations.refetch() + } catch (error) { + notifyError(error, 'Could not acknowledge Wisdom notifications') + } + }} + onPlanAction={(action, event) => planManagedActionForSkill(event.skill_id, action)} + /> +
+ + {copy.sharedSkills(rows.length)}} + right={ + qualifiedCandidates.length > 0 ? ( + + {copy.localCandidates(qualifiedCandidates.length)} + + ) : undefined + } + /> + } + > + {rows.map(skill => ( + + ))} + {rows.length === 0 && ( +
{copy.noShared}
+ )} +
+ +
+ {((candidates.data?.candidates.length ?? 0) > 0 || activeDrafts.length > 0) && ( +
+
+

{copy.potential}

+

{copy.potentialHelp}

+
+ {visibleQualifiedCandidates.map(candidate => ( +
+
+
{candidateDisplayName(candidate)}
+ {candidateDisplayDescription(candidate) && ( +
+ {candidateDisplayDescription(candidate)} +
+ )} +
+ {candidateSummary(candidate)} +
+ {candidate.professionalism_check && ( +
+ +
+ )} +
+ +
+ ))} + {visibleQualifiedCandidates.length === 0 && ( +

{copy.noSuggestions}

+ )} + {manualCandidates.length > 0 && ( +
+ + {showManualCandidates && ( + <> +

+ {copy.browseLocalHelp} +

+
+ {visibleManualCandidates.map(candidate => ( +
+
+
+ {candidateDisplayName(candidate)} +
+ {candidateDisplayDescription(candidate) && ( +
+ {candidateDisplayDescription(candidate)} +
+ )} +
+ {candidateSummary(candidate)} +
+ {candidate.professionalism_check && ( +
+ +
+ )} +
+ +
+ ))} +
+ + )} +
+ )} +
+
+ +
+

{copy.ownerReview}

+

{copy.ownerReviewHelp}

+
+ {activeDrafts.length === 0 ? ( +

{copy.noDrafts}

+ ) : ( + activeDrafts.map(draft => ( + + )) + )} +
+
+
+ )} + + {detail.data && ( +
+

{String(detail.data.skill.slug || detail.data.skill.id)}

+

+ {String(detail.data.skill.authorDescription || detail.data.skill.author_description || '')} +

+ +

{copy.versionHistory}

+
+                  {JSON.stringify(
+                    {
+                      latest_version: detail.data.latest_version_detail,
+                      version_history: detail.data.versions,
+                      local_compatibility: detail.data.local_compatibility
+                    },
+                    null,
+                    2
+                  )}
+                
+ {content.data && ( +
+
content {content.data.content_hash}
+ {content.data.files.map(file => ( +
+ + {file.path} · {file.hash} + +
+                          {file.content_utf8}
+                        
+
+ ))} +
+ )} +
+ {installed ? ( + <> + + {copy.installed(installed.version, installed.update_mode)} + + {selectedUpdate && ( + + {copy.updateAvailable(selectedUpdate.plan?.version)} + + )} + + + + ) : ( + + )} +
+
+ )} +
+
+
+ + {publicationDraftId && ( +
+ { + setPublicationDraftId(null) + void refreshContributionData() + }} + onSubmitted={() => void refreshContributionData()} + profile={profile} + /> +
+ )} + + {actionPlan && ( +
+

{copy.confirmAction(actionPlan.action)}

+
+
{JSON.stringify(actionPlan, null, 2)}
+
+
+ {actionPlan.action === 'install' && ( +
+ + +

+ {copy.updateModeHelp} +

+
+ )} + {actionPlan.state === 'current' &&

{copy.alreadyCurrent}

} + {actionPlan.compatibility && actionPlan.compatibility.outcome !== 'compatible' && ( + + )} + {(actionPlan.sensitive_expansion?.length ?? 0) > 0 && ( + + )} + {actionPlan.modified && actionPlan.update_mode !== 'REQUIRED' && ( + + )} +
+ + {actionPlan.state !== 'current' && ( + + )} +
+
+
+ )} +
+ ) +} diff --git a/apps/desktop/src/app/skills/index.test.tsx b/apps/desktop/src/app/skills/index.test.tsx index b4647437dd..6af7ca4805 100644 --- a/apps/desktop/src/app/skills/index.test.tsx +++ b/apps/desktop/src/app/skills/index.test.tsx @@ -19,6 +19,7 @@ const getUsageAnalytics = vi.fn() const getProfiles = vi.fn() const getSkillContent = vi.fn() const getOfficialSkills = vi.fn() +const getWisdomEntitlement = vi.fn() // Partial mock: keep the real module (SkillsView pulls in @/store/profile, // whose import-time subscription calls setApiRequestProfile) and stub only the @@ -36,7 +37,8 @@ vi.mock('@/hermes', async importOriginal => ({ getUsageAnalytics: (days: number, profile?: null | string) => getUsageAnalytics(days, profile), getProfiles: () => getProfiles(), getSkillContent: (name: string, profile?: null | string) => getSkillContent(name, profile), - getOfficialSkills: (profile?: null | string) => getOfficialSkills(profile) + getOfficialSkills: (profile?: null | string) => getOfficialSkills(profile), + getWisdomEntitlement: (profile?: null | string) => getWisdomEntitlement(profile) })) // Notifications hit nanostores/timers we don't care about here. @@ -45,6 +47,15 @@ vi.mock('@/store/notifications', () => ({ notifyError: vi.fn() })) +// Tab contents have their own suites; this suite owns route-to-panel selection. +vi.mock('@/components/chat/code-editor', () => ({ CodeEditor: () => null })) +vi.mock('./collective-tab', () => ({ + CollectiveTab: () =>
+})) +vi.mock('./plugins-tab', () => ({ + PluginsTab: () =>
+})) + // The catalog Install button routes through the hub action pipeline — stub the // action entrypoint (real module kept: SkillsView reads $hubActions and the // query keys from it). @@ -75,19 +86,22 @@ function toolset(overrides: Record = {}) { } } -async function renderSkills() { +async function renderSkills(tab = 'toolsets') { const { SkillsView } = await import('./index') let result: ReturnType await act(async () => { result = render( // SkillsView reads skills/toolsets via useQuery, so it needs a provider. - + ) }) + if (vi.isFakeTimers()) { + await act(async () => { await vi.advanceTimersByTimeAsync(50) }) + } return result! } @@ -99,6 +113,12 @@ beforeEach(() => { getToolsetConfig.mockResolvedValue({ has_category: true, active_provider: null, providers: [] }) getUsageAnalytics.mockResolvedValue({ tools: [] }) getOfficialSkills.mockResolvedValue({ skills: [] }) + getWisdomEntitlement.mockResolvedValue({ + entitled: true, + org_id: 'org-1', + scopes: ['wisdom:read'], + expires_at: Date.now() / 1000 + 60 + }) getSkillContent.mockResolvedValue({ name: 'web-research', path: '/skills/web-research/SKILL.md', @@ -111,6 +131,7 @@ beforeEach(() => { afterEach(() => { cleanup() + vi.useRealTimers() vi.clearAllMocks() // Shared singleton client — drop cached skills/toolsets so each test refetches. queryClient.clear() @@ -122,6 +143,117 @@ afterEach(() => { // (2× in a row on PR #93612, plus a main run the same hour). Give this file // headroom; the tests are not slow individually. describe('SkillsView toolset management', { timeout: 60_000 }, () => { + it.each([ + ['collective', 'Collective workspace', 'Plugins workspace'], + ['plugins', 'Plugins workspace', 'Collective workspace'] + ])('opens the %s deep link without replacing the other workspace', async (tab, selected, other) => { + await renderSkills(tab) + + expect(await screen.findByRole('region', { name: selected })).toBeTruthy() + expect(screen.queryByRole('region', { name: other })).toBeNull() + expect(navigateSpy).not.toHaveBeenCalledWith( + { pathname: '/skills', search: '', hash: '' }, + { replace: true } + ) + const otherTab = other.replace(' workspace', '') + fireEvent.click(screen.getByRole('button', { name: otherTab })) + expect(navigateSpy).toHaveBeenCalledWith( + { pathname: '/skills', search: `?tab=${otherTab.toLowerCase()}`, hash: '' }, + { replace: true } + ) + }) + + it('hides Collective Wisdom and redirects its deep link when local entitlement is absent', async () => { + getWisdomEntitlement.mockResolvedValue({ entitled: false, org_id: null, scopes: [], expires_at: null }) + await renderSkills('collective') + + expect(screen.queryByRole('button', { name: 'Collective' })).toBeNull() + expect(screen.queryByRole('region', { name: 'Collective workspace' })).toBeNull() + await waitFor(() => + expect(navigateSpy).toHaveBeenCalledWith( + { pathname: '/skills', search: '', hash: '' }, + { replace: true } + ) + ) + }) + + it('fails closed when an entitlement response is already expired', async () => { + getWisdomEntitlement.mockResolvedValue({ + entitled: true, + org_id: 'org-1', + scopes: ['wisdom:read'], + expires_at: Date.now() / 1000 - 1 + }) + + await renderSkills('collective') + + expect(screen.queryByRole('button', { name: 'Collective' })).toBeNull() + expect(screen.queryByRole('region', { name: 'Collective workspace' })).toBeNull() + }) + + it('rechecks entitlement after switching away and back while probes are pending', async () => { + const { SkillsView } = await import('./index') + const page = (profile: string) => ( + + + + + + ) + const result = render(page('eligible')) + expect(await screen.findByRole('button', { name: 'Collective' })).toBeTruthy() + + getWisdomEntitlement.mockImplementation(() => new Promise(() => {})) + result.rerender(page('other')) + expect(screen.queryByRole('button', { name: 'Collective' })).toBeNull() + result.rerender(page('eligible')) + expect(screen.queryByRole('button', { name: 'Collective' })).toBeNull() + }) + + it('hides Collective when its positive JWT reaches expires_at', async () => { + vi.useFakeTimers() + const expiresAt = Date.now() / 1000 + 1 + getWisdomEntitlement.mockResolvedValue({ + entitled: true, + org_id: 'org-1', + scopes: ['wisdom:read'], + expires_at: expiresAt + }) + + await renderSkills('collective') + expect(screen.queryByRole('region', { name: 'Collective workspace' })).not.toBeNull() + + await act(async () => { + await vi.advanceTimersByTimeAsync(1_001) + }) + + expect(screen.queryByRole('button', { name: 'Collective' })).toBeNull() + expect(screen.queryByRole('region', { name: 'Collective workspace' })).toBeNull() + }) + + it('fails closed when a recheck errors after a positive result', async () => { + vi.useFakeTimers() + getWisdomEntitlement + .mockResolvedValueOnce({ + entitled: true, + org_id: 'org-1', + scopes: ['wisdom:read'], + expires_at: Date.now() / 1000 + 60 + }) + .mockRejectedValue(new Error('probe failed')) + + await renderSkills('collective') + expect(screen.queryByRole('region', { name: 'Collective workspace' })).not.toBeNull() + + await act(async () => { + await vi.advanceTimersByTimeAsync(15_000) + }) + + expect(getWisdomEntitlement).toHaveBeenCalledTimes(2) + expect(screen.queryByRole('button', { name: 'Collective' })).toBeNull() + expect(screen.queryByRole('region', { name: 'Collective workspace' })).toBeNull() + }) + it('renders a switch for each toolset and toggles it off', async () => { await renderSkills() @@ -283,6 +415,37 @@ describe('SkillsView toolset management', { timeout: 60_000 }, () => { expect(await screen.findByText(/Deep research steps/)).toBeTruthy() }) + it('uses editorial skill copy while keeping canonical identifiers for actions', async () => { + getSkills.mockResolvedValue([ + { + name: 'web-research', + description: 'Use when researching the web.', + editorial_name: 'Research the Web', + editorial_description: 'Find and compare trustworthy sources online.', + category: 'research', + enabled: true, + usage: 3, + provenance: 'bundled' + } + ]) + + const { SkillsView } = await import('./index') + await act(async () => { + render( + + + + + + ) + }) + + expect((await screen.findAllByText('Research the Web')).length).toBeGreaterThan(0) + expect(screen.getByText('Find and compare trustworthy sources online.')).toBeTruthy() + expect(screen.queryByText('Use when researching the web.')).toBeNull() + expect(getSkillContent).toHaveBeenCalledWith('web-research', 'default') + }) + it('hub picker refuses to reinstall an already-installed skill', async () => { const { notify } = await import('@/store/notifications') const { EmbeddedHubPicker } = await import('./embedded-hub-picker') diff --git a/apps/desktop/src/app/skills/index.tsx b/apps/desktop/src/app/skills/index.tsx index 3a17f97b62..bb27334944 100644 --- a/apps/desktop/src/app/skills/index.tsx +++ b/apps/desktop/src/app/skills/index.tsx @@ -21,6 +21,7 @@ import { getSkills, getToolsets, getUsageAnalytics, + getWisdomEntitlement, previewSkillHub, type ProfileScope, profileScopeKey, @@ -67,6 +68,7 @@ import { TerminalBackendPanel } from '../settings/terminal-backend-panel' import { ToolsetConfigPanel } from '../settings/toolset-config-panel' import type { SetStatusbarItemGroup } from '../shell/statusbar-controls' +import { CollectiveTab } from './collective-tab' import { EmbeddedHubPicker } from './embedded-hub-picker' import { McpTab } from './mcp-tab' import { PluginsTab } from './plugins-tab' @@ -75,7 +77,7 @@ import { $skillsSortDesc, $toolsetsSortDesc } from './store' // 'hub' is gone as a top-level tab — the Skills Hub browser lives inside the // Skills tab now (EmbeddedHubPicker below the installed list). Legacy // `?tab=hub` links fall back to 'skills' via useRouteEnumParam. -const SKILLS_MODES = ['skills', 'toolsets', 'mcp', 'plugins'] as const +const SKILLS_MODES = ['skills', 'toolsets', 'mcp', 'plugins', 'collective'] as const // Skills + toolsets live in the RQ cache so switching tabs/pages paints the // cached lists instantly (no reload flash) and mount only fires a deduped @@ -119,6 +121,18 @@ const usageOf = (skill: SkillInfo): number => (typeof skill.usage === 'number' ? const categoryFor = (skill: SkillInfo): string => asText(skill.category) || 'general' +type SkillPresentation = { + description: string + editorial_description?: string + editorial_name?: string + name: string +} + +const skillDisplayName = (skill: SkillPresentation): string => asText(skill.editorial_name) || skill.name + +const skillDisplayDescription = (skill: SkillPresentation): string => + asText(skill.editorial_description) || skill.description + // Row subtitle: category, with non-default origins badged. function skillSubtitle(skill: SkillInfo): React.ReactNode { const category = prettyName(categoryFor(skill)) @@ -148,9 +162,14 @@ function filteredSkills(skills: SkillInfo[], query: string, desc: boolean): Skil return skills .filter( skill => - !q || includesQuery(skill.name, q) || includesQuery(skill.description, q) || includesQuery(skill.category, q) + !q || + includesQuery(skill.name, q) || + includesQuery(skill.description, q) || + includesQuery(skillDisplayName(skill), q) || + includesQuery(skillDisplayDescription(skill), q) || + includesQuery(skill.category, q) ) - .sort((a, b) => sign * (usageOf(b) - usageOf(a)) || asText(a.name).localeCompare(asText(b.name))) + .sort((a, b) => sign * (usageOf(b) - usageOf(a)) || skillDisplayName(a).localeCompare(skillDisplayName(b))) } // Catalog rows have no usage yet — plain A–Z, same query fields as installed @@ -164,10 +183,12 @@ function filteredOfficial(skills: OfficialSkillInfo[], query: string): OfficialS !q || includesQuery(skill.name, q) || includesQuery(skill.description, q) || + includesQuery(skillDisplayName(skill), q) || + includesQuery(skillDisplayDescription(skill), q) || includesQuery(skill.category, q) || skill.tags.some(tag => includesQuery(tag, q)) ) - .sort((a, b) => asText(a.name).localeCompare(asText(b.name))) + .sort((a, b) => skillDisplayName(a).localeCompare(skillDisplayName(b))) } const toolsetCalls = (toolset: ToolsetInfo, toolCalls: Record): number => @@ -288,6 +309,67 @@ export function SkillsView({ // the wrong machine — withhold it for cross-backend scopes. const crossBackendScope = scopeConnectionId !== null && scopeConnectionId !== (activeGatewayConnectionId() ?? 'local') + // Poll the refresh-free local claim probe so an open page cannot retain a + // positive gate indefinitely. A profile change is fail-closed even if React + // Query has cached that profile from an earlier visit. + const wisdomEntitlement = useQuery({ + queryKey: ['wisdom-entitlement', scopeKey], + queryFn: () => getWisdomEntitlement(scopeProfile), + staleTime: 0, + refetchInterval: 15_000, + retry: false + }) + // Identity changes on every scope visit, including A -> B -> A. + const wisdomScope = useMemo(() => ({ key: scopeKey }), [scopeKey]) + const [acceptedWisdomResult, setAcceptedWisdomResult] = useState(null) + + const [entitlementClock, setEntitlementClock] = useState(() => Date.now()) + + useEffect(() => { + if (wisdomEntitlement.isSuccess && wisdomEntitlement.fetchStatus === 'idle') { + setAcceptedWisdomResult({ scope: wisdomScope, updatedAt: wisdomEntitlement.dataUpdatedAt }) + } + }, [wisdomScope, wisdomEntitlement.dataUpdatedAt, wisdomEntitlement.fetchStatus, wisdomEntitlement.isSuccess]) + + const entitlementExpiresAt = wisdomEntitlement.data?.expires_at + useEffect(() => { + if (typeof entitlementExpiresAt !== 'number') { + return + } + + const remaining = entitlementExpiresAt * 1000 - Date.now() + + if (remaining <= 0) { + setEntitlementClock(Date.now()) + + return + } + + const timeout = window.setTimeout(() => setEntitlementClock(Date.now()), Math.min(remaining, 2_147_483_647)) + + return () => window.clearTimeout(timeout) + }, [entitlementExpiresAt]) + + const freshWisdomEntitlement = + wisdomEntitlement.data?.entitled === true && + typeof entitlementExpiresAt === 'number' && + entitlementExpiresAt * 1000 > entitlementClock + const wisdomEntitled = + acceptedWisdomResult?.scope === wisdomScope && + acceptedWisdomResult.updatedAt === wisdomEntitlement.dataUpdatedAt && + !wisdomEntitlement.isError && + freshWisdomEntitlement + const wisdomDenied = wisdomEntitlement.isError || (wisdomEntitlement.isSuccess && !freshWisdomEntitlement) + + useEffect(() => { + if (mode === 'collective' && wisdomDenied && !wisdomEntitlement.isFetching) { + setMode('skills') + } + }, [mode, setMode, wisdomDenied, wisdomEntitlement.isFetching]) + const { data: profilesData } = useQuery({ queryKey: ['capabilities-profiles'], queryFn: getProfiles, @@ -864,13 +946,20 @@ export function SkillsView({ // searching it is noise. searchHidden={mode === 'mcp' || mode === 'plugins'} searchHints={searchHints} - searchPlaceholder={mode === 'skills' ? t.skills.searchSkills : t.skills.searchToolsets} + searchPlaceholder={ + mode === 'skills' + ? t.skills.searchSkills + : mode === 'collective' && wisdomEntitled + ? t.skills.searchCollective + : t.skills.searchToolsets + } searchValue={query} tabs={[ { id: 'skills', label: t.skills.tabSkills, meta: skills?.length ?? null }, { id: 'toolsets', label: t.skills.tabToolsets, meta: toolsets ? visibleToolsetCount(toolsets) : null }, { id: 'mcp', label: t.skills.tabMcp }, - { id: 'plugins', label: t.skills.tabPlugins } + { id: 'plugins', label: t.skills.tabPlugins }, + ...(wisdomEntitled ? [{ id: 'collective', label: t.skills.tabCollective }] : []) ]} > {/* One shared column: the scope selector sits above whichever tab is @@ -882,8 +971,10 @@ export function SkillsView({ must not sit under a "Configuring: " header. */} {mode !== 'plugins' && profileScopeSelector}
-
- {mode === 'plugins' ? ( +
+ {mode === 'collective' && wisdomEntitled ? ( + + ) : mode === 'plugins' ? ( // Agent plugins for the scoped profile (selector in the section // header), app-level desktop plugins, the live catalog picker // underneath. Keyed on scope so a profile/connection switch @@ -958,8 +1049,8 @@ export function SkillsView({ }} onToggle={enabled => void handleToggleSkill(skill, enabled)} subtitle={skillSubtitle(skill)} - title={skill.name} - toggleLabel={skill.name} + title={skillDisplayName(skill)} + toggleLabel={skillDisplayName(skill)} /> ))} {/* The built-in optional-skills catalog, below the @@ -992,7 +1083,7 @@ export function SkillsView({ key={skill.identifier} onSelect={() => setSelectedOfficial(skill.identifier)} subtitle={prettyName(skill.category)} - title={skill.name} + title={skillDisplayName(skill)} /> ) })} @@ -1214,7 +1305,7 @@ function SkillDetail({ return ( <> {prettyName(categoryFor(skill))} @@ -1225,7 +1316,7 @@ function SkillDetail({ )} } - title={skill.name} + title={skillDisplayName(skill)} /> {editable && (
@@ -1292,14 +1383,14 @@ function OfficialSkillDetail({ return ( <> {prettyName(skill.category)} {t.skills.officialPill} } - title={skill.name} + title={skillDisplayName(skill)} />
+ +
+ )} + {supportsPreview && mode === 'preview' ? ( +
+ +
+ ) : ( +