refactor(skills): make Skill Workshop a direct, versioned self-learning loop (#161057)

* refactor(skills): simplify Skill Workshop into a direct, versioned learning loop

* refactor(skills): replace weekly curator with unused-skill cleanup; add remove_file and used-skill review trigger

* fix(skills): record Code Mode skill reads as use; clearer learned-skill notice and undo

* fix(skills): give the next turn the exact revert call for a learned change

* chore(skills): log unused-skill cleanup archives

* fix(skills): pin learned-change undo to the pre-review version; split skill locks

* fix(skills): keep legacy skill descriptions editable; credit review changes to the reviewed conversation

* fix(skills): harden Skill Workshop writes, migration recovery, and review notices

Recheck live write authority under the skill lock, publish snapshots atomically, keep a review's undo target, cancel stale queued reviews, announce changes from failed reviews in the right thread and session, never replay dispatched CLI mutations locally, and recover unfinished proposal applies and unowned proposals during the Doctor retirement.

* fix(skills): check Workshop authority at each file effect, fence notices to the reviewed session generation

Recheck the caller's live authority immediately before every write, remove, rename and snapshot publish; pin only a review's own undo anchor so the ten-version limit holds across reviews; fence notices, transcript appends and channel sends on the reviewed lifecycle revision; keep proposal rollback records when an unfinished apply is in a mixed state; retire a Control UI mode write when the Gateway connection changes. Version storage moves to skill-versions.ts.

* fix(skills): pass Workshop authority into fs-safe, drop review version pinning

Hand the live-authority check to fs-safe's assertBeforeMutation for writes and removes; remove per-review version pinning in favor of the plain ten-version limit; clear mode switch state when the Gateway or agent changes; remove the empty directory left by an undone partial create during proposal retirement.

* test(e2e): treat the retired Workshop review table as healthy in the upgrade witness

* fix(skills): clear Skill Workshop simplification CI fallout

- Break the state worker contract import cycle: WorkshopChange now lives with its SQL owner (changes.kernel).
- Delete code orphaned by the proposal removal (multi-lease state worker path, apply-mutation path, attestation retire, frontmatter/usage helpers) and the unused ui diff dependency.
- Ratchet protocol module count (69) and public SDK export budget (4586); drop the stale enabled=true->propose doctor test.
- Fix mock Workshop lint and keep raw DDL allow comment where the Kysely guard reads it.

* test(qa-lab): read the mock tool-search target from the QA marker

The mock took the first target= anywhere in the request text. The
skill_workshop description ("Set target=personal ...") precedes the user
prompt, so the delegation real-gateway lane searched for "personal" and
never called openclaw.

* fix(skills): skip ACP sessions in reviews, ignore symlinked versions, keep drafts of removed agents

Adversarial review follow-ups: ACP child sessions no longer trigger background reviews; version listing ignores symlinked entries; Doctor keeps proposals whose recorded owner is no longer configured; remove the stale cron-disabled collection-review warning and retirement log text; docs point exported drafts at /learn.

* feat(skills): let background reviews look things up with read-only tools

Reviews can read files, list directories, search/fetch the web, and look up past sessions and memory; skill_workshop stays the only tool that changes anything. Hermes saw the same denial storm with a write-tool-only review.

* fix(skills): count a turn's assistant messages when the harness reports zero iterations

Codex reports model iterations from raw response events, which resumed threads do not
emit, so every turn after the first reported 0 and the review counter never reached ten.

* style(ui): compose Skill Workshop from the settings and Carapace primitives

Learning mode and learn-from-past become one settings group whose description states what the current mode does. Skills, archived skills, and the change feed use shared settings rows; the feed leads with the skill, shows the reason, then who/when, with Undo as the row control. The page now loads the hub stylesheet so its header matches Plugins and Skills on a cold load. The mock seeds a richer feed.

* fix(ui): name the learning Start button for assistive tech

* fix(skills): keep learned skills that a cron job names out of unused cleanup

* feat(skills): advisory authoring feedback and merge hints on Workshop writes

Each SKILL.md write reports up to three non-blocking findings it introduced (description length, sprawl, no-op words, negations, update narratives) and the closest overlapping live skill, with the merge recipe. Authoring standards and the review and /learn prompts adopt the writing-for-agents levers and make merging overlapping skills part of the normal flow.

* refactor(skills): let the agent judge duplicate skills instead of a word-overlap score

A create now lists the agent's other learned skills so the model decides whether to merge; the Jaccard overlap heuristic is removed.

* fix(skills): clear merge deadcode and route Workshop review test to database workers

Unexport helpers whose only non-test consumers were removed with the proposal machinery (resolvedSkillUsageMatch, lintSkillMarkdown, WORKSPACE_ATTESTATION_RECENT_MS), test the lint rules through skillWriteAdvisories, and run experience-review.test.ts in the database-worker lane now that review preparation is worker-backed.

* test(skills): align main's skill-read test with always-visible learned skills; drop dead max-lines suppression

* chore(skills): prune ratchet baselines and move the removed-hooks compat record into its own module

* chore(pr): list the removed-hooks compat record in the PR wrapper inventory

* test(skills): pass real exports through Workshop test mocks and prune the mock-export baseline

* fix(skills): swap restored skills by rename so a refused publish keeps the live skill; whole-bundle recovery guidance

* fix(skills): remove archived skills through the guarded removal owner; check authority right before the restore swap

* fix(skills): archive by one fenced rename so a revoked archive leaves the skill whole

* fix(doctor): name both recoveries for a Skill Workshop draft whose agent is gone

* test(state): create the retired Workshop table before indexing it in the relocated preflight test

* fix(skills): bind request authority to operator Workshop archive and restore

* test: route model directive tests through the SQLite host

Model selection now prepares placement reads through the shared-state broker.
Run the three existing directive consumers in the forked database-worker owner
while preserving production behavior and every fixture assertion.

Validation: 172 affected cases and 90 canonical routing cases passed; P2 review
found no actionable issues. The original broker failures also reproduce on
main 83a95401 and PR d5fb3076.

Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>

* test: await plugin maintenance before reload assertions

Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>

* test: keep restart observer device identity separate

Reproduce the original authority rejection in the network-isolated E2E, then preserve the interrupted Control UI credential by giving the non-inline observer its own device identity. The same real process-restart case now passes with unchanged durable completion and single-final-reply assertions. No production authority or token-rotation changes. Independent P0/P1 review and scoped lint/format checks pass.

Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>

* test: preserve install hook coverage across Workshop cutover

Main moved the independent before_install priority/blocking test into the now-retired proposal lifecycle suite. Keep that exact case in its original owning file while removing the proposal hooks. Event and assertions are byte-identical to b300e0ed; focused test and lint pass. No production or compatibility changes.

Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>

* fix(transcripts): retain captured logical writer selectors

Keep physical database admission pinned to captured.path while preserving captured.ownerStorePath for writer authority. Reproduce the valid logical mirror rejection and protect revoked, foreign, and post-dispatch mutated requests. All 21 locked-write regressions and independent P0/P1 review pass; final style fixes only add required braces. Full local QA replay remains a coverage gap because the Crabline getMe prerequisite returned HTTP 500 before Gateway startup.

Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>

* fix(sessions): reuse settled baseline after delayed claim responses

Revalidate the authoritative generation inside the existing shared capture promise before starting another capture. Reuse durable successful and unavailable outcomes; keep settlement fencing unchanged. Deterministic before cases captured twice, after cases once. Focused 18 tests pass on the integrated candidate; 73 sibling tests and type graphs passed in the isolated repair checkout. Independent P0/P1 review is clean.

Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>

* test(doctor): assert future-version refusal at maintenance admission

The fixture ledger reader now admits its catalog before version validation, so the deliberately malformed index is its expected preliminary refusal. Preserve the outer exact newer-schema error and zero-service-stop assertions at Doctor admission. Original future-version case reproduced failure before reaching Doctor; all 45 maintenance cases pass after correcting the fixture. No production version policy changes.

Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>

* fix(state): retain current schema receipts on late reader observations

Carry already-read foreign schema markers through the existing notification. Preserve only receipts already validated for both markers; local DDL, mismatched markers, identity changes and revoked receipts remain refused. Real worker publication/adoption regression fails before and passes after. Integrated 98 focused tests pass, with 124 tested in the isolated repair checkout; independent P0/P1 review is clean. Exact QA4 causality remains unproven.

Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>

* test(codex): exercise real side-question sender policy

Distinguish explicit sender denial from missing managed search credentials. Keep disabled and cached controls with no managed search tool; production policy is unchanged.

Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>

* fix: restore CI coverage for nonblocking session titles

Align the deletion regression with metadata-only title generation and join its late writer before checking that the deleted row stays absent.

Co-authored-by: RomneyDa <6581799+RomneyDa@users.noreply.github.com>
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>

* test(ci): use a canonical unit-fast inventory fixture

Code Mode now belongs to agents-core and correctly runs on Node. Use a pure unit-fast fixture in the synthetic Bun envelope instead; keep every runtime inventory and conservation assertion. Original three cases fail before; all 221 runner tests pass after. No runtime selection or qualification policy changed.

Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>

* test(qa): show bounded node-load terminal errors

Include the sanitized terminal payload in the existing failing assertion without changing status, load, timeout or authority checks. The exact failing merge passed one isolated three-wave load run with this diagnostic and a disk-backed fixture temporary parent; this is non-reproduction, not a production repair or flakiness qualification.

Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>

* fix: keep worker turns running during placement inventory refresh

Reuse the existing unconsumed-read preparation owner for maintenance inventory. Refresh reads invalidated before publication while retaining published-snapshot, unknown-write, cancellation and closure fences.

Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Co-authored-by: RomneyDa <6581799+RomneyDa@users.noreply.github.com>
This commit is contained in:
Ayaan Zaidi 2026-10-08 14:33:41 +08:00 • committed by GitHub
parent bc162d9e05
commit cfee851150
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
556 changed files with 9860 additions and 55400 deletions

View file

@ -333,7 +333,6 @@
{"path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsRoute.kt","kind":"ui-call","surface":"android","id":"native.android.dbe222ea8860b0cd","source":"Profile & appearance"},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsRoute.kt","kind":"ui-call","surface":"android","id":"native.android.99ef92cbd4f7f8fe","source":"Providers & Models"},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsRoute.kt","kind":"ui-call","surface":"android","id":"native.android.09f52861cf88d4f2","source":"Settings"},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsRoute.kt","kind":"ui-call","surface":"android","id":"native.android.5ff7428eec141e51","source":"Skill Workshop"},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsRoute.kt","kind":"ui-call","surface":"android","id":"native.android.1f5be29bbc17bce0","source":"Skills"},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsRoute.kt","kind":"ui-call","surface":"android","id":"native.android.5e81b684e1330769","source":"System"},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsRoute.kt","kind":"ui-call","surface":"android","id":"native.android.fb5079a200a2f3ef","source":"Terminal"},
@ -1369,16 +1368,13 @@
{"path":"apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt","kind":"ui-call","surface":"android","id":"native.android.a9729f5cdbef366e","source":"Automation started."},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt","kind":"ui-call","surface":"android","id":"native.android.0fc5a74dbec866a1","source":"Automation updated."},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt","kind":"ui-call","surface":"android","id":"native.android.ca51a1c9a1b24826","source":"Connect the gateway to inspect ClawHub skills."},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt","kind":"ui-call","surface":"android","id":"native.android.2049005db60d0f04","source":"Connect the gateway to inspect Skill Workshop proposals."},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt","kind":"ui-call","surface":"android","id":"native.android.c8e0cc413546fde9","source":"Connect the gateway to inspect automation run history."},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt","kind":"ui-call","surface":"android","id":"native.android.b8d9b52e97ffe2c0","source":"Connect the gateway to inspect automations."},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt","kind":"ui-call","surface":"android","id":"native.android.ef4f2d1ad5de2f3b","source":"Connect the gateway to install ClawHub skills."},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt","kind":"ui-call","surface":"android","id":"native.android.02787d41f91fe185","source":"Connect the gateway to load Skill Workshop proposals."},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt","kind":"ui-call","surface":"android","id":"native.android.2fb1545cb43bf5f0","source":"Connect the gateway to load session catalogs."},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt","kind":"ui-call","surface":"android","id":"native.android.4eb8efe9ea8c2f9f","source":"Connect the gateway to manage automations."},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt","kind":"ui-call","surface":"android","id":"native.android.6367db46deb26f53","source":"Connect the gateway to open this session."},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt","kind":"ui-call","surface":"android","id":"native.android.304ffb8821c28a65","source":"Connect the gateway to search ClawHub skills."},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt","kind":"ui-call","surface":"android","id":"native.android.ae02a58e7b49306e","source":"Connect the gateway to update Skill Workshop proposals."},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt","kind":"ui-call","surface":"android","id":"native.android.a80b22b7a29180c3","source":"Connect the gateway to update skills."},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt","kind":"ui-call","surface":"android","id":"native.android.c5fd8089d1035820","source":"Connect to a Gateway to save wake words"},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt","kind":"ui-call","surface":"android","id":"native.android.837744e8aa3c8155","source":"Connected"},
@ -1386,11 +1382,8 @@
{"path":"apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt","kind":"ui-call","surface":"android","id":"native.android.7914e03e567f698c","source":"Connected (operator offline)"},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt","kind":"ui-call","surface":"android","id":"native.android.c21025024a07e967","source":"Connected (operator: $operator)"},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt","kind":"ui-call","surface":"android","id":"native.android.5e5d2fdf5bb9d763","source":"Connecting…"},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt","kind":"ui-call","surface":"android","id":"native.android.f7e64f9b9b8675e1","source":"Could not ${action.verb} Skill Workshop proposal."},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt","kind":"ui-call","surface":"android","id":"native.android.b23629d72bfebc10","source":"Could not inspect Skill Workshop proposal."},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt","kind":"ui-call","surface":"android","id":"native.android.d37c22282a1c0b5c","source":"Could not install ${slug} from ClawHub."},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt","kind":"ui-call","surface":"android","id":"native.android.cee7ae53f86ff1c8","source":"Could not load ClawHub details for ${skill.reference}."},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt","kind":"ui-call","surface":"android","id":"native.android.551ea7691d01ab36","source":"Could not load Skill Workshop proposals."},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt","kind":"ui-call","surface":"android","id":"native.android.ea55566c0ceef818","source":"Could not load approval details. Refresh and try again."},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt","kind":"ui-call","surface":"android","id":"native.android.bd645044e93a6877","source":"Could not load approvals."},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt","kind":"ui-call","surface":"android","id":"native.android.91cc378bbf16e120","source":"Could not load automation run history."},
@ -1421,18 +1414,13 @@
{"path":"apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt","kind":"ui-call","surface":"android","id":"native.android.a3909017b16578ca","source":"Gateway connection timed out. Check your network and that the Gateway is running, then retry."},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt","kind":"ui-call","surface":"android","id":"native.android.3aa7b171f760eaf3","source":"Gateway rejected the automation run."},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt","kind":"ui-call","surface":"android","id":"native.android.aa5a155373c94365","source":"Gateway returned an invalid automation."},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt","kind":"ui-call","surface":"android","id":"native.android.b4ee90cdbf0ebbfb","source":"Gateway returned status '$statusLabel' after ${action.verb}."},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt","kind":"ui-call","surface":"android","id":"native.android.3dd02e8b5c8d3052","source":"No prompt"},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt","kind":"ui-call","surface":"android","id":"native.android.dad3625d27b761a4","source":"Offline"},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt","kind":"ui-call","surface":"android","id":"native.android.59857d295b71b6f6","source":"Paired device removed."},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt","kind":"ui-call","surface":"android","id":"native.android.0e27fbb1014bad1d","source":"Pairing request rejected."},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt","kind":"ui-call","surface":"android","id":"native.android.221a6a9d33f0c721","source":"Proposal applied."},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt","kind":"ui-call","surface":"android","id":"native.android.a7ada63ca275548e","source":"Proposal quarantined."},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt","kind":"ui-call","surface":"android","id":"native.android.a5cfcdfeeee691fc","source":"Proposal rejected."},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt","kind":"ui-call","surface":"android","id":"native.android.4eb766157a1ba746","source":"Provider models loaded, but readiness is unavailable."},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt","kind":"ui-call","surface":"android","id":"native.android.377c08edb4a24718","source":"Reconnecting…"},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt","kind":"ui-call","surface":"android","id":"native.android.fa2fac51b4d2989d","source":"Resolution outcome unknown. Actions stay disabled until the Gateway record is verified."},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt","kind":"ui-call","surface":"android","id":"native.android.3981fcbf350402fd","source":"Skill Workshop proposal actions require operator.admin scope."},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt","kind":"ui-call","surface":"android","id":"native.android.44fb00ccaf9544cc","source":"Some models could not be refreshed. Tap Refresh to retry."},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt","kind":"ui-call","surface":"android","id":"native.android.a5916add82ec0498","source":"Start failed: $message"},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt","kind":"ui-call","surface":"android","id":"native.android.93c22448c6d9be7b","source":"The Gateway still shows this approval as pending. Review it before trying again."},
@ -1444,10 +1432,6 @@
{"path":"apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt","kind":"ui-call","surface":"android","id":"native.android.8072001855422732","source":"This session cannot be continued."},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt","kind":"ui-call","surface":"android","id":"native.android.8a282588633bbc18","source":"Update your Gateway to view provider model config."},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt","kind":"ui-call","surface":"android","id":"native.android.9ce3afc4d0d3732a","source":"Wake words saved"},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt","kind":"ui-call","surface":"android","id":"native.android.5731193296f6ca25","source":"apply"},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt","kind":"ui-call","surface":"android","id":"native.android.0253be221b0f9263","source":"quarantine"},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt","kind":"ui-call","surface":"android","id":"native.android.731933bdb3e5416f","source":"reject"},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt","kind":"ui-call","surface":"android","id":"native.android.15e2709f1d0cc737","source":"unknown"},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt","kind":"ui-named-argument","surface":"android","id":"native.android.837744e8aa3c8155","source":"Connected"},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt","kind":"ui-named-argument","surface":"android","id":"native.android.5e5d2fdf5bb9d763","source":"Connecting…"},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt","kind":"ui-named-argument","surface":"android","id":"native.android.dad3625d27b761a4","source":"Offline"},
@ -1719,42 +1703,6 @@
{"path":"apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSessionSidebar.swift","kind":"ui-localized-call","surface":"apple","id":"native.apple.2d85575ed1bdaceb","source":"The thread and its transcript are removed from the gateway."},
{"path":"apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSessionSidebar.swift","kind":"ui-localized-call","surface":"apple","id":"native.apple.2afc7addbd339c16","source":"Thread name"},
{"path":"apps/ios/Sources/Status/VoiceWakeToast.swift","kind":"ui-modifier","surface":"apple","id":"native.apple.edd16a0d56c61dc2","source":"Voice Wake triggered"},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SkillWorkshopSettingsScreen.kt","kind":"ui-call","surface":"android","id":"native.android.8d00775305a3fc74","source":"Applied"},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SkillWorkshopSettingsScreen.kt","kind":"ui-call","surface":"android","id":"native.android.b973587e84c65063","source":"Apply"},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SkillWorkshopSettingsScreen.kt","kind":"ui-call","surface":"android","id":"native.android.b758a4df60af280c","source":"Apply proposal?"},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SkillWorkshopSettingsScreen.kt","kind":"ui-call","surface":"android","id":"native.android.56ba8fc553a399af","source":"Apply, reject, and quarantine require operator.admin scope. Reconnect with shared gateway auth or approve an operator.admin device scope upgrade to enable lifecycle actions."},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SkillWorkshopSettingsScreen.kt","kind":"ui-call","surface":"android","id":"native.android.ac43ebebc177b67f","source":"Connect to a Gateway to load Skill Workshop proposals."},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SkillWorkshopSettingsScreen.kt","kind":"ui-call","surface":"android","id":"native.android.b5fc14ea1bed1613","source":"Default agent"},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SkillWorkshopSettingsScreen.kt","kind":"ui-call","surface":"android","id":"native.android.9246a0bf9cf3b80d","source":"Gateway offline"},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SkillWorkshopSettingsScreen.kt","kind":"ui-call","surface":"android","id":"native.android.8e3561cf3f9ef366","source":"Held"},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SkillWorkshopSettingsScreen.kt","kind":"ui-call","surface":"android","id":"native.android.2de1cf5173f6e5d4","source":"Inspect"},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SkillWorkshopSettingsScreen.kt","kind":"ui-call","surface":"android","id":"native.android.40d6f39ca9212457","source":"Inspect this proposal to load its markdown."},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SkillWorkshopSettingsScreen.kt","kind":"ui-call","surface":"android","id":"native.android.87987fee763f208a","source":"Inspecting"},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SkillWorkshopSettingsScreen.kt","kind":"ui-call","surface":"android","id":"native.android.48079eb3e4909507","source":"Kind"},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SkillWorkshopSettingsScreen.kt","kind":"ui-call","surface":"android","id":"native.android.ccc5760f719f1aac","source":"Loading"},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SkillWorkshopSettingsScreen.kt","kind":"ui-call","surface":"android","id":"native.android.caa655dfb72453d2","source":"Matching proposals will appear here after agents create reusable skill drafts."},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SkillWorkshopSettingsScreen.kt","kind":"ui-call","surface":"android","id":"native.android.2d831fd5829656fc","source":"No proposals"},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SkillWorkshopSettingsScreen.kt","kind":"ui-call","surface":"android","id":"native.android.ba9093fc4494e812","source":"Open"},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SkillWorkshopSettingsScreen.kt","kind":"ui-call","surface":"android","id":"native.android.d44392114f134b3b","source":"Pending"},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SkillWorkshopSettingsScreen.kt","kind":"ui-call","surface":"android","id":"native.android.bbdbd5723a2288ef","source":"Quarantine"},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SkillWorkshopSettingsScreen.kt","kind":"ui-call","surface":"android","id":"native.android.4f0253b22e3c699d","source":"Quarantine proposal?"},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SkillWorkshopSettingsScreen.kt","kind":"ui-call","surface":"android","id":"native.android.762f31cc51a61c6a","source":"Refresh"},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SkillWorkshopSettingsScreen.kt","kind":"ui-call","surface":"android","id":"native.android.0bba7f5a7f536bfc","source":"Refreshing"},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SkillWorkshopSettingsScreen.kt","kind":"ui-call","surface":"android","id":"native.android.d6d7d3bb54de86f7","source":"Reject"},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SkillWorkshopSettingsScreen.kt","kind":"ui-call","surface":"android","id":"native.android.2faa88096a79b17a","source":"Reject proposal?"},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SkillWorkshopSettingsScreen.kt","kind":"ui-call","surface":"android","id":"native.android.b7d8a4c3e34cc092","source":"Rejected"},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SkillWorkshopSettingsScreen.kt","kind":"ui-call","surface":"android","id":"native.android.3b7bab21e700d42b","source":"Review generated skill proposals before they become live skills."},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SkillWorkshopSettingsScreen.kt","kind":"ui-call","surface":"android","id":"native.android.8263e0efaec65c14","source":"Search proposals"},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SkillWorkshopSettingsScreen.kt","kind":"ui-call","surface":"android","id":"native.android.db30a49dac4dedb3","source":"Selected"},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SkillWorkshopSettingsScreen.kt","kind":"ui-call","surface":"android","id":"native.android.df62ed35fb847c60","source":"Skill Workshop inspect and apply actions"},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SkillWorkshopSettingsScreen.kt","kind":"ui-call","surface":"android","id":"native.android.edbfa88792176f3f","source":"Skill Workshop reject and quarantine actions"},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SkillWorkshopSettingsScreen.kt","kind":"ui-call","surface":"android","id":"native.android.79c5d6232b243c1d","source":"Support Files"},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SkillWorkshopSettingsScreen.kt","kind":"ui-call","surface":"android","id":"native.android.845376b224b1e70b","source":"This will apply \"$proposalTitle\" and refresh Skill Workshop state from the gateway."},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SkillWorkshopSettingsScreen.kt","kind":"ui-call","surface":"android","id":"native.android.d19557a5bc60a77b","source":"This will quarantine \"$proposalTitle\" and refresh Skill Workshop state from the gateway."},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SkillWorkshopSettingsScreen.kt","kind":"ui-call","surface":"android","id":"native.android.b3f73faa993fddb0","source":"This will reject \"$proposalTitle\" and refresh Skill Workshop state from the gateway."},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SkillWorkshopSettingsScreen.kt","kind":"ui-call","surface":"android","id":"native.android.39785345ba2c3bff","source":"Unknown"},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SkillWorkshopSettingsScreen.kt","kind":"ui-call","surface":"android","id":"native.android.cb4541d4534f81bd","source":"Updated"},
{"path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SkillWorkshopSettingsScreen.kt","kind":"ui-call","surface":"android","id":"native.android.e15885377b28fa59","source":"Working"},
{"path":"apps/ios/Sources/Design/ChatProTab.swift","kind":"conditional-branch","surface":"apple","id":"native.apple.98ff0a205ca51067","source":"Collapsed"},
{"path":"apps/ios/Sources/Design/ChatProTab.swift","kind":"conditional-branch","surface":"apple","id":"native.apple.d22afdc8f4adfb5d","source":"Connected"},
{"path":"apps/ios/Sources/Design/ChatProTab.swift","kind":"conditional-branch","surface":"apple","id":"native.apple.5936a4a11d4f206a","source":"Expanded"},

View file

@ -17,7 +17,7 @@ OpenClaw Android is the officially released Google Play app. It connects to an O
- Setup offers optional notifications, microphone, camera, and location access; other permissions are under **Additional features**. **Request all** asks for the available runtime permissions without enabling the camera or location features; Android handles consent through its system prompts. Notification access stays separate under **Special access**. Continue without granting access, or enable phone capabilities individually or later in Settings. Blocked individual setup requests offer Android Settings. Biometric locking, Gateway/chat notifications, and authenticated background presence are supported.
- SMS is available only in third-party builds on supported telephony devices. Setup distinguishes partial read/send access. Android Settings can recover user denial, but installer or device-policy restrictions may leave SMS unavailable; OpenClaw cannot override them. Returning from Settings rereads the actual grants without restarting Gateway setup.
- View the phone's memory and disk meters on the Control UI Devices page. Connected Android nodes report host resource stats immediately and every 60 seconds; disk meters require an available storage sample and a Gateway that supports host stats.
- Manage installed skills and Gateway-verified ClawHub releases, review Skill Workshop proposals, and inspect or edit automations with the required Gateway access.
- Manage installed skills and Gateway-verified ClawHub releases, and inspect or edit automations with the required Gateway access.
- Use the Wear OS companion for sessions, replies, aborts, and realtime Talk through the paired phone without storing Gateway credentials on the watch.
## Open in Android Studio

View file

@ -569,13 +569,6 @@ class MainViewModel internal constructor(
val skillMutationKeys: StateFlow<Set<String>> = runtimeState(initial = emptySet()) { it.skillMutationKeys }
val clawHubSkillSearchState: StateFlow<GatewayClawHubSkillSearchState> =
runtimeState(initial = GatewayClawHubSkillSearchState()) { it.clawHubSkillSearchState }
val skillWorkshopSummary: StateFlow<GatewaySkillWorkshopSummary> =
runtimeState(initial = GatewaySkillWorkshopSummary(proposals = emptyList())) { it.skillWorkshopSummary }
val skillWorkshopRefreshing: StateFlow<Boolean> = runtimeState(initial = false) { it.skillWorkshopRefreshing }
val skillWorkshopErrorText: StateFlow<String?> = runtimeState(initial = null) { it.skillWorkshopErrorText }
val skillWorkshopNoticeText: StateFlow<String?> = runtimeState(initial = null) { it.skillWorkshopNoticeText }
val skillWorkshopInspectingProposalId: StateFlow<String?> = runtimeState(initial = null) { it.skillWorkshopInspectingProposalId }
val skillWorkshopMutatingProposalId: StateFlow<String?> = runtimeState(initial = null) { it.skillWorkshopMutatingProposalId }
val nodesDevicesSummary: StateFlow<GatewayNodesDevicesSummary> =
runtimeState(initial = GatewayNodesDevicesSummary(nodes = emptyList(), pendingDevices = emptyList(), pairedDevices = emptyList())) { it.nodesDevicesSummary }
val nodesDevicesRefreshing: StateFlow<Boolean> = runtimeState(initial = false) { it.nodesDevicesRefreshing }
@ -1461,30 +1454,6 @@ class MainViewModel internal constructor(
fun refreshSkills(): Unit = ensureRuntime().refreshSkills()
fun refreshSkillWorkshopProposals(agentId: String? = null): Unit = ensureRuntime().refreshSkillWorkshopProposals(agentId = agentId)
fun resetSkillWorkshopAgentScope(agentId: String? = null): Unit = ensureRuntime().resetSkillWorkshopAgentScope(agentId = agentId)
fun inspectSkillWorkshopProposal(
proposalId: String,
agentId: String? = null,
): Unit = ensureRuntime().inspectSkillWorkshopProposal(proposalId = proposalId, agentId = agentId)
fun applySkillWorkshopProposal(
proposalId: String,
agentId: String? = null,
): Unit = ensureRuntime().applySkillWorkshopProposal(proposalId = proposalId, agentId = agentId)
fun rejectSkillWorkshopProposal(
proposalId: String,
agentId: String? = null,
): Unit = ensureRuntime().rejectSkillWorkshopProposal(proposalId = proposalId, agentId = agentId)
fun quarantineSkillWorkshopProposal(
proposalId: String,
agentId: String? = null,
): Unit = ensureRuntime().quarantineSkillWorkshopProposal(proposalId = proposalId, agentId = agentId)
fun setSkillEnabled(
skillKey: String,
enabled: Boolean,

View file

@ -422,35 +422,6 @@ internal fun verifyGatewayDevicePairingMutation(
internal fun buildGatewayDevicePairingMutationParams(mutation: GatewayDevicePairingMutation): JsonObject = buildJsonObject { put(mutation.action.idKey, JsonPrimitive(mutation.targetId)) }
internal enum class SkillWorkshopGatewayAction(
val methodSuffix: String,
val expectedStatus: String,
val notice: NativeText,
val verb: NativeText,
) {
Apply("apply", "applied", nativeText("Proposal applied."), nativeText("apply")),
Reject("reject", "rejected", nativeText("Proposal rejected."), nativeText("reject")),
Quarantine("quarantine", "quarantined", nativeText("Proposal quarantined."), nativeText("quarantine")),
}
internal fun skillWorkshopUnexpectedStatusText(
status: String?,
action: SkillWorkshopGatewayAction,
): NativeText {
val statusText = status?.takeIf { it.isNotBlank() }?.let(::verbatimText) ?: nativeText("unknown")
return nativeText(
"Gateway returned status '\$statusLabel' after \${action.verb}.",
statusText,
action.verb,
)
}
internal fun skillWorkshopActionFailureText(action: SkillWorkshopGatewayAction): NativeText =
nativeText(
"Could not \${action.verb} Skill Workshop proposal.",
action.verb,
)
internal data class PendingNotificationNodeEvent(
val event: String,
val payloadJson: String?,
@ -1278,21 +1249,6 @@ class NodeRuntime internal constructor(
private val clawHubSkillSearchSeq = AtomicLong(0)
private val clawHubSkillReviewSeq = AtomicLong(0)
private val clawHubSkillInstallMutex = Mutex()
private val _skillWorkshopSummary = MutableStateFlow(GatewaySkillWorkshopSummary(proposals = emptyList()))
val skillWorkshopSummary: StateFlow<GatewaySkillWorkshopSummary> = _skillWorkshopSummary.asStateFlow()
private val _skillWorkshopRefreshing = MutableStateFlow(false)
val skillWorkshopRefreshing: StateFlow<Boolean> = _skillWorkshopRefreshing.asStateFlow()
private val _skillWorkshopErrorText = MutableStateFlow<NativeText?>(null)
val skillWorkshopErrorText: StateFlow<String?> = _skillWorkshopErrorText.resolveOptionalNativeText()
private val _skillWorkshopNoticeText = MutableStateFlow<NativeText?>(null)
val skillWorkshopNoticeText: StateFlow<String?> = _skillWorkshopNoticeText.resolveOptionalNativeText()
private val _skillWorkshopInspectingProposalId = MutableStateFlow<String?>(null)
val skillWorkshopInspectingProposalId: StateFlow<String?> = _skillWorkshopInspectingProposalId.asStateFlow()
private val _skillWorkshopMutatingProposalId = MutableStateFlow<String?>(null)
val skillWorkshopMutatingProposalId: StateFlow<String?> = _skillWorkshopMutatingProposalId.asStateFlow()
private val skillWorkshopListSeq = AtomicLong(0)
private val skillWorkshopInspectSeq = AtomicLong(0)
private val skillWorkshopMutationSeq = AtomicLong(0)
private val emptyNodesDevicesSummary =
GatewayNodesDevicesSummary(nodes = emptyList(), pendingDevices = emptyList(), pairedDevices = emptyList())
private val _nodesDevicesSummary = MutableStateFlow(emptyNodesDevicesSummary)
@ -1789,15 +1745,6 @@ class NodeRuntime internal constructor(
clawHubSkillSearchSeq.incrementAndGet()
clawHubSkillReviewSeq.incrementAndGet()
_clawHubSkillSearchState.value = GatewayClawHubSkillSearchState()
_skillWorkshopSummary.value = GatewaySkillWorkshopSummary(proposals = emptyList())
_skillWorkshopRefreshing.value = false
_skillWorkshopErrorText.value = null
_skillWorkshopNoticeText.value = null
_skillWorkshopInspectingProposalId.value = null
_skillWorkshopMutatingProposalId.value = null
skillWorkshopListSeq.incrementAndGet()
skillWorkshopInspectSeq.incrementAndGet()
skillWorkshopMutationSeq.incrementAndGet()
_nodesDevicesSummary.value = emptyNodesDevicesSummary
_nodesDevicesRefreshing.value = false
_nodesDevicesErrorText.value = null
@ -2711,63 +2658,6 @@ class NodeRuntime internal constructor(
)
}
fun refreshSkillWorkshopProposals(agentId: String? = null) {
scope.launch {
refreshSkillWorkshopProposalsFromGateway(agentId = agentId)
}
}
fun resetSkillWorkshopAgentScope(agentId: String? = null) {
val normalizedAgentId = normalizeSkillWorkshopAgentId(agentId)
skillWorkshopListSeq.incrementAndGet()
skillWorkshopInspectSeq.incrementAndGet()
skillWorkshopMutationSeq.incrementAndGet()
_skillWorkshopSummary.value = GatewaySkillWorkshopSummary(agentId = normalizedAgentId, proposals = emptyList())
_skillWorkshopRefreshing.value = false
_skillWorkshopErrorText.value = null
_skillWorkshopNoticeText.value = null
_skillWorkshopInspectingProposalId.value = null
_skillWorkshopMutatingProposalId.value = null
}
fun inspectSkillWorkshopProposal(
proposalId: String,
agentId: String? = null,
) {
val normalized = proposalId.trim()
if (normalized.isEmpty()) return
scope.launch {
requestSkillWorkshopProposal(proposalId = normalized, agentId = agentId)
}
}
fun applySkillWorkshopProposal(
proposalId: String,
agentId: String? = null,
) = mutateSkillWorkshopProposal(proposalId = proposalId, agentId = agentId, action = SkillWorkshopGatewayAction.Apply)
fun rejectSkillWorkshopProposal(
proposalId: String,
agentId: String? = null,
) = mutateSkillWorkshopProposal(proposalId = proposalId, agentId = agentId, action = SkillWorkshopGatewayAction.Reject)
fun quarantineSkillWorkshopProposal(
proposalId: String,
agentId: String? = null,
) = mutateSkillWorkshopProposal(proposalId = proposalId, agentId = agentId, action = SkillWorkshopGatewayAction.Quarantine)
private fun mutateSkillWorkshopProposal(
proposalId: String,
agentId: String?,
action: SkillWorkshopGatewayAction,
) {
val normalized = proposalId.trim()
if (normalized.isEmpty()) return
scope.launch {
requestSkillWorkshopProposal(proposalId = normalized, agentId = agentId, action = action)
}
}
fun refreshNodesDevices() = launchGatewayRefresh { refreshNodesDevicesFromGateway() }
fun approveNodeCapabilities(expectedRequestId: String) {
@ -7308,184 +7198,6 @@ class NodeRuntime internal constructor(
?: isClawHubSkillInstalledByReference(skills, slug)
}
private suspend fun refreshSkillWorkshopProposalsFromGateway(agentId: String?) {
val listSeq = skillWorkshopListSeq.incrementAndGet()
val requestAgentId = normalizeSkillWorkshopAgentId(agentId)
val gatewayScope = captureGatewayDataScope()
if (gatewayScope == null || !operatorConnected) {
_skillWorkshopSummary.value = GatewaySkillWorkshopSummary(agentId = requestAgentId, proposals = emptyList())
_skillWorkshopRefreshing.value = false
_skillWorkshopErrorText.value = nativeText("Connect the gateway to load Skill Workshop proposals.")
return
}
publishGatewayData(gatewayScope) {
_skillWorkshopRefreshing.value = true
_skillWorkshopErrorText.value = null
if (_skillWorkshopSummary.value.agentId != requestAgentId) {
_skillWorkshopSummary.value = GatewaySkillWorkshopSummary(agentId = requestAgentId, proposals = emptyList())
_skillWorkshopNoticeText.value = null
_skillWorkshopInspectingProposalId.value = null
_skillWorkshopMutatingProposalId.value = null
skillWorkshopInspectSeq.incrementAndGet()
skillWorkshopMutationSeq.incrementAndGet()
}
}
try {
val root =
requestGatewayObject(
gatewayScope,
"skills.proposals.list",
skillWorkshopParams(agentId = agentId).toString(),
)
val previousById =
_skillWorkshopSummary.value
.takeIf { it.agentId == requestAgentId }
?.proposals
?.associateBy { it.id }
.orEmpty()
val proposals = parseSkillWorkshopProposals(root?.get("proposals") as? JsonArray, previousById)
publishGatewayData(gatewayScope) {
if (skillWorkshopListSeq.get() == listSeq && _skillWorkshopSummary.value.agentId == requestAgentId) {
_skillWorkshopSummary.value = GatewaySkillWorkshopSummary(agentId = requestAgentId, proposals = proposals)
}
}
} catch (err: CancellationException) {
throw err
} catch (_: Throwable) {
publishGatewayData(gatewayScope) {
if (skillWorkshopListSeq.get() == listSeq && _skillWorkshopSummary.value.agentId == requestAgentId) {
_skillWorkshopErrorText.value = nativeText("Could not load Skill Workshop proposals.")
}
}
} finally {
publishGatewayData(gatewayScope) {
if (skillWorkshopListSeq.get() == listSeq && _skillWorkshopSummary.value.agentId == requestAgentId) {
_skillWorkshopRefreshing.value = false
}
}
}
}
private suspend fun requestSkillWorkshopProposal(
proposalId: String,
agentId: String?,
action: SkillWorkshopGatewayAction? = null,
) {
var requestSeq: Long? = null
val requestAgentId = normalizeSkillWorkshopAgentId(agentId)
if (action != null && !operatorAdminScopeAvailable.value) {
_skillWorkshopErrorText.value = nativeText("Skill Workshop proposal actions require operator.admin scope.")
return
}
val gatewayScope = captureGatewayDataScope()
if (gatewayScope == null || !operatorConnected) {
_skillWorkshopErrorText.value =
if (action == null) {
nativeText("Connect the gateway to inspect Skill Workshop proposals.")
} else {
nativeText("Connect the gateway to update Skill Workshop proposals.")
}
return
}
val sequence = if (action == null) skillWorkshopInspectSeq else skillWorkshopMutationSeq
val activeProposal = if (action == null) _skillWorkshopInspectingProposalId else _skillWorkshopMutatingProposalId
publishGatewayData(gatewayScope) {
val currentSummary = _skillWorkshopSummary.value
if (
currentSummary.agentId == requestAgentId &&
currentSummary.proposals.any { it.id == proposalId } &&
_skillWorkshopMutatingProposalId.value == null
) {
requestSeq = sequence.incrementAndGet()
if (action != null) {
// Mutations retire detail reads before changing the proposal's lifecycle state.
skillWorkshopInspectSeq.incrementAndGet()
_skillWorkshopInspectingProposalId.value = null
}
activeProposal.value = proposalId
_skillWorkshopErrorText.value = null
if (action != null) _skillWorkshopNoticeText.value = null
}
}
if (requestSeq == null) return
fun current() = sequence.get() == requestSeq && _skillWorkshopSummary.value.agentId == requestAgentId
try {
val root =
requestGatewayObject(
gatewayScope,
"skills.proposals.${action?.methodSuffix ?: "inspect"}",
skillWorkshopParams(agentId = agentId, proposalId = proposalId).toString(),
)
val previous =
_skillWorkshopSummary.value
.takeIf { it.agentId == requestAgentId }
?.proposals
?.firstOrNull { it.id == proposalId }
if (action == null) {
val inspected =
parseSkillWorkshopProposalInspect(root, previous)
?: throw IllegalStateException("skills.proposals.inspect returned no proposal")
publishGatewayData(gatewayScope) {
val currentSummary = _skillWorkshopSummary.value
if (
sequence.get() == requestSeq &&
currentSummary.agentId == requestAgentId &&
currentSummary.proposals.any { it.id == proposalId }
) {
_skillWorkshopSummary.value = _skillWorkshopSummary.value.withProposal(inspected)
}
}
} else {
val updatedProposal = parseSkillWorkshopProposalActionResult(root, previous)
val mutationConfirmed =
withCurrentGatewayData(gatewayScope) {
if (!current()) return@withCurrentGatewayData false
if (updatedProposal?.status == action.expectedStatus) {
_skillWorkshopSummary.value = _skillWorkshopSummary.value.withProposal(updatedProposal)
_skillWorkshopNoticeText.value = action.notice
true
} else {
_skillWorkshopErrorText.value = skillWorkshopUnexpectedStatusText(updatedProposal?.status, action)
false
}
}
if (!mutationConfirmed) return
if (withCurrentGatewayData(gatewayScope, ::current)) refreshSkillWorkshopProposalsFromGateway(agentId = agentId)
}
} catch (err: CancellationException) {
throw err
} catch (_: Throwable) {
publishGatewayData(gatewayScope) {
if (current()) {
_skillWorkshopErrorText.value =
if (action == null) {
nativeText("Could not inspect Skill Workshop proposal.")
} else {
skillWorkshopActionFailureText(action)
}
}
}
} finally {
publishGatewayData(gatewayScope) {
if (current()) activeProposal.value = null
}
}
}
private fun normalizeSkillWorkshopAgentId(agentId: String?): String = agentId?.trim().orEmpty()
private fun skillWorkshopParams(
agentId: String?,
proposalId: String? = null,
): JsonObject =
buildJsonObject {
val normalizedAgentId = agentId?.trim()?.takeIf { it.isNotEmpty() }
if (normalizedAgentId != null) put("agentId", JsonPrimitive(normalizedAgentId))
val normalizedProposalId = proposalId?.trim()?.takeIf { it.isNotEmpty() }
if (normalizedProposalId != null) put("proposalId", JsonPrimitive(normalizedProposalId))
}
private suspend fun mutateDevicePairingOnGateway(
gatewayScope: GatewayDataScope,
mutation: GatewayDevicePairingMutation,
@ -8558,82 +8270,6 @@ class NodeRuntime internal constructor(
)
}
private fun parseSkillWorkshopProposals(
proposals: JsonArray?,
previousById: Map<String, GatewaySkillWorkshopProposal>,
): List<GatewaySkillWorkshopProposal> =
proposals
.mapObjects { record ->
val id = record.nonBlankString("id") ?: return@mapObjects null
parseSkillWorkshopProposalRecord(record, previousById[id], listEntry = true)
}.sortedByDescending { it.updatedAt }
private fun parseSkillWorkshopProposalInspect(
root: JsonObject?,
previous: GatewaySkillWorkshopProposal?,
): GatewaySkillWorkshopProposal? {
val source = root ?: return null
val record = source["record"].asObjectOrNull() ?: return null
return parseSkillWorkshopProposalRecord(record, previous)?.copy(
content = stripSkillWorkshopFrontmatter(source["content"].asStringOrNull().orEmpty()),
supportFiles = parseSkillWorkshopSupportFiles(source["supportFiles"] as? JsonArray),
)
}
private fun parseSkillWorkshopProposalActionResult(
root: JsonObject?,
previous: GatewaySkillWorkshopProposal?,
): GatewaySkillWorkshopProposal? {
val record =
root?.get("record").asObjectOrNull()
?: root?.takeIf { it.nonBlankString("status") != null }
?: return null
val proposal = parseSkillWorkshopProposalRecord(record, previous) ?: return null
return proposal.copy(scanState = record["scan"].asObjectOrNull().nonBlankString("state") ?: proposal.scanState)
}
private fun parseSkillWorkshopProposalRecord(
record: JsonObject,
previous: GatewaySkillWorkshopProposal?,
listEntry: Boolean = false,
): GatewaySkillWorkshopProposal? {
// Lists replace metadata; detail/action records may omit it. Only unchanged list
// revisions retain inspected content, while action records preserve it across updates.
val fallback = previous.takeUnless { listEntry }
val id = record.nonBlankString("id") ?: fallback?.id ?: return null
val target = if (listEntry) record else record["target"].asObjectOrNull()
val updatedAt = record.nonBlankString("updatedAt").orEmpty()
val details = previous?.takeIf { !listEntry || it.updatedAt == updatedAt }
return GatewaySkillWorkshopProposal(
id = id,
kind = record.nonBlankString("kind") ?: fallback?.kind ?: "proposal",
status = record.nonBlankString("status") ?: fallback?.status ?: "pending",
title = record.nonBlankString("title") ?: target?.nonBlankString("skillName") ?: fallback?.title ?: id,
description = record.nonBlankString("description") ?: fallback?.description,
skillName = target?.nonBlankString("skillName") ?: fallback?.skillName ?: id,
skillKey = target?.nonBlankString("skillKey") ?: fallback?.skillKey ?: id,
createdAt = record.nonBlankString("createdAt") ?: fallback?.createdAt.orEmpty(),
updatedAt = updatedAt.ifEmpty { fallback?.updatedAt.orEmpty() },
scanState = record.nonBlankString("scanState") ?: fallback?.scanState,
content = details?.content,
supportFiles = details?.supportFiles.orEmpty(),
)
}
private fun parseSkillWorkshopSupportFiles(files: JsonArray?): List<GatewaySkillWorkshopSupportFile> =
files.mapObjects { obj ->
val path = obj.nonBlankString("path") ?: return@mapObjects null
GatewaySkillWorkshopSupportFile(
path = path,
content = obj["content"].asStringOrNull()?.takeIf { it.isNotEmpty() },
)
}
private fun stripSkillWorkshopFrontmatter(content: String): String {
val withoutFrontmatter = content.replace(Regex("(?s)^---\\r?\\n.*?\\r?\\n---\\r?\\n?"), "")
return withoutFrontmatter.trim()
}
private fun skillMissingCount(missing: JsonObject?): Int = listOf("bins", "env", "config", "os").sumOf { key -> (missing?.get(key) as? JsonArray)?.size ?: 0 }
private fun parsePendingDevices(devices: JsonArray?): List<GatewayPendingDeviceSummary> =
@ -8977,38 +8613,6 @@ data class GatewaySkillsSummary(
val skills: List<GatewaySkillSummary>,
)
data class GatewaySkillWorkshopSummary(
val agentId: String = "",
val proposals: List<GatewaySkillWorkshopProposal>,
) {
fun withProposal(proposal: GatewaySkillWorkshopProposal): GatewaySkillWorkshopSummary =
copy(
proposals =
(proposals.filterNot { it.id == proposal.id } + proposal)
.sortedByDescending { it.updatedAt },
)
}
data class GatewaySkillWorkshopProposal(
val id: String,
val kind: String,
val status: String,
val title: String,
val description: String?,
val skillName: String,
val skillKey: String,
val createdAt: String,
val updatedAt: String,
val scanState: String?,
val content: String? = null,
val supportFiles: List<GatewaySkillWorkshopSupportFile> = emptyList(),
)
data class GatewaySkillWorkshopSupportFile(
val path: String,
val content: String?,
)
data class GatewaySkillSummary(
val skillKey: String,
val name: String,

View file

@ -58,7 +58,7 @@ data class GatewayCredentials(
internal val defaultSidebarVisiblePages = listOf("home", "threads", "skills", "work")
internal val defaultSidebarPageOrder =
defaultSidebarVisiblePages + listOf("agents", "automations", "usage", "skill-workshop", "dreaming", "terminal", "desktop")
defaultSidebarVisiblePages + listOf("agents", "automations", "usage", "dreaming", "terminal", "desktop")
internal fun sanitizeSidebarPageOrder(pageIds: List<String>): List<String> {
val knownIds = defaultSidebarPageOrder.toSet()

View file

@ -30,7 +30,6 @@ internal enum class SettingsRoute(
CronJobs(nativeText("Automations"), ClawIcons.Automations, SettingsCategory.Workspace),
Usage(nativeText("Usage"), ClawIcons.Usage, SettingsCategory.Workspace),
Skills(nativeText("Skills"), ClawIcons.Skills, SettingsCategory.Workspace),
SkillWorkshop(nativeText("Skill Workshop"), ClawIcons.SkillWorkshop, SettingsCategory.Workspace),
SystemAgent(nativeText("OpenClaw"), ClawIcons.OpenClaw, null),
NodesDevices(nativeText("Nodes & Devices"), ClawIcons.Devices, SettingsCategory.Connection),
Channels(nativeText("Channels"), ClawIcons.Channels, SettingsCategory.Connection),

View file

@ -192,7 +192,6 @@ internal fun SettingsDetailScreen(
SettingsRoute.CronJobs -> CronJobsSettingsScreen(viewModel = viewModel, onBack = onBack)
SettingsRoute.Usage -> UsageSettingsScreen(viewModel = viewModel, onBack = onBack)
SettingsRoute.Skills -> SkillsSettingsScreen(viewModel = viewModel, onBack = onBack)
SettingsRoute.SkillWorkshop -> SkillWorkshopSettingsScreen(viewModel = viewModel, onBack = onBack)
SettingsRoute.SystemAgent -> SystemAgentSettingsScreen(viewModel = viewModel, onBack = onBack)
SettingsRoute.NodesDevices -> NodesDevicesSettingsScreen(viewModel = viewModel, onBack = onBack)
SettingsRoute.Channels -> ChannelsSettingsScreen(viewModel = viewModel, onBack = onBack)

View file

@ -130,7 +130,6 @@ internal enum class SidebarDestination(
Agents(stableId = "agents", settingsRoute = SettingsRoute.Agents),
Automations(stableId = "automations", settingsRoute = SettingsRoute.CronJobs),
Usage(stableId = "usage", settingsRoute = SettingsRoute.Usage),
SkillWorkshop(stableId = "skill-workshop", settingsRoute = SettingsRoute.SkillWorkshop),
Dreaming(stableId = "dreaming", settingsRoute = SettingsRoute.Dreaming),
Terminal(stableId = "terminal", settingsRoute = SettingsRoute.Terminal),
Desktop(stableId = "desktop", settingsRoute = SettingsRoute.Desktop),

View file

@ -1,624 +0,0 @@
package ai.openclaw.app.ui
import ai.openclaw.app.GatewayAgentSummary
import ai.openclaw.app.GatewaySkillWorkshopProposal
import ai.openclaw.app.GatewaySkillWorkshopSummary
import ai.openclaw.app.MainViewModel
import ai.openclaw.app.i18n.NativeText
import ai.openclaw.app.i18n.nativeString
import ai.openclaw.app.i18n.nativeText
import ai.openclaw.app.i18n.resolveNativeText
import ai.openclaw.app.selectableAgents
import ai.openclaw.app.ui.design.ClawPanel
import ai.openclaw.app.ui.design.ClawPrimaryButton
import ai.openclaw.app.ui.design.ClawSecondaryButton
import ai.openclaw.app.ui.design.ClawSegmentedControl
import ai.openclaw.app.ui.design.ClawStatus
import ai.openclaw.app.ui.design.ClawStatusPill
import ai.openclaw.app.ui.design.ClawTextField
import ai.openclaw.app.ui.design.ClawTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
private val skillWorkshopFilters =
listOf(
"pending" to "Pending",
"held" to "Held",
"applied" to "Applied",
"rejected" to "Rejected",
"all" to "All",
)
@Composable
internal fun SkillWorkshopSettingsScreen(
viewModel: MainViewModel,
onBack: () -> Unit,
) {
val summary by viewModel.skillWorkshopSummary.collectAsState()
val refreshing by viewModel.skillWorkshopRefreshing.collectAsState()
val errorText by viewModel.skillWorkshopErrorText.collectAsState()
val noticeText by viewModel.skillWorkshopNoticeText.collectAsState()
val inspectingProposalId by viewModel.skillWorkshopInspectingProposalId.collectAsState()
val mutatingProposalId by viewModel.skillWorkshopMutatingProposalId.collectAsState()
val isConnected by viewModel.isConnected.collectAsState()
val operatorAdminScopeAvailable by viewModel.operatorAdminScopeAvailable.collectAsState()
val agents by viewModel.gatewayAgents.collectAsState()
val defaultAgentId by viewModel.gatewayDefaultAgentId.collectAsState()
var statusFilter by rememberSaveable { mutableStateOf("pending") }
var query by rememberSaveable { mutableStateOf("") }
var selectedAgentId by rememberSaveable { mutableStateOf("") }
var selectedProposalId by rememberSaveable { mutableStateOf<String?>(null) }
var pendingAction by remember { mutableStateOf<SkillWorkshopPendingAction?>(null) }
val selectedAgentParam = selectedAgentId.trim().takeIf { it.isNotEmpty() }
val visibleProposals = skillWorkshopVisibleProposals(summary, selectedAgentParam)
val filteredProposals = skillWorkshopFilteredProposals(visibleProposals, statusFilter, query)
val selectedProposal =
filteredProposals.firstOrNull { it.id == selectedProposalId }
?: filteredProposals.firstOrNull()
LaunchedEffect(isConnected, selectedAgentParam) {
if (isConnected) {
viewModel.refreshSkillWorkshopProposals(agentId = selectedAgentParam)
}
}
LaunchedEffect(filteredProposals.map { it.id }) {
if (selectedProposalId == null || filteredProposals.none { it.id == selectedProposalId }) {
selectedProposalId = filteredProposals.firstOrNull()?.id
}
}
LaunchedEffect(selectedProposal?.id, selectedProposal?.content, isConnected, selectedAgentParam) {
if (isConnected && selectedProposal != null && selectedProposal.content == null) {
viewModel.inspectSkillWorkshopProposal(proposalId = selectedProposal.id, agentId = selectedAgentParam)
}
}
LaunchedEffect(selectedProposal?.id, pendingAction?.proposalId) {
if (pendingAction != null && selectedProposal?.id != pendingAction?.proposalId) {
pendingAction = null
}
}
pendingAction?.let { action ->
SkillWorkshopActionConfirmDialog(
action = action,
onDismiss = { pendingAction = null },
onConfirm = {
pendingAction = null
action.action.perform(viewModel, action.proposalId, selectedAgentParam)
},
)
}
SettingsDetailFrame(
subtitle = nativeString("Review generated skill proposals before they become live skills."),
route = SettingsRoute.SkillWorkshop,
onBack = onBack,
) {
SettingsMetricPanel(
rows =
listOf(
SettingsMetric(nativeString("Pending"), visibleProposals.count { it.status == "pending" }.toString()),
SettingsMetric(
nativeString("Held"),
visibleProposals.count { skillWorkshopStatusMatchesFilter(it.status, "held") }.toString(),
),
SettingsMetric(nativeString("Applied"), visibleProposals.count { it.status == "applied" }.toString()),
SettingsMetric(nativeString("Rejected"), visibleProposals.count { it.status == "rejected" }.toString()),
),
)
SkillWorkshopControls(
agents = agents,
defaultAgentId = defaultAgentId,
selectedAgentId = selectedAgentId,
onAgentChange = { agentId ->
selectedAgentId = agentId
selectedProposalId = null
viewModel.resetSkillWorkshopAgentScope(agentId = agentId.takeIf { it.isNotBlank() })
},
statusFilter = statusFilter,
onStatusFilterChange = { filter ->
statusFilter = filter
selectedProposalId = null
},
query = query,
onQueryChange = { value ->
query = value
selectedProposalId = null
},
refreshing = refreshing,
isConnected = isConnected,
onRefresh = { viewModel.refreshSkillWorkshopProposals(agentId = selectedAgentParam) },
)
noticeText?.let { message ->
SettingsMessagePanel(text = message, color = ClawTheme.colors.success)
}
errorText?.let { message ->
SettingsMessagePanel(text = message, color = ClawTheme.colors.warning)
}
when {
!isConnected -> {
SettingsMessagePanel(
spacing = 4.dp,
titleStyle = ClawTheme.type.title,
title = nativeString("Gateway offline"),
text = nativeString("Connect to a Gateway to load Skill Workshop proposals."),
)
}
filteredProposals.isEmpty() -> {
SettingsMessagePanel(
spacing = 4.dp,
titleStyle = ClawTheme.type.title,
title = nativeString("No proposals"),
text = nativeString("Matching proposals will appear here after agents create reusable skill drafts."),
)
}
else -> {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
filteredProposals.forEach { proposal ->
SkillWorkshopProposalRow(
proposal = proposal,
selected = proposal.id == selectedProposal?.id,
busy = proposal.id == inspectingProposalId || proposal.id == mutatingProposalId,
onClick = {
selectedProposalId = proposal.id
viewModel.inspectSkillWorkshopProposal(proposalId = proposal.id, agentId = selectedAgentParam)
},
)
}
}
selectedProposal?.let { proposal ->
SkillWorkshopProposalDetail(
proposal = proposal,
inspecting = inspectingProposalId == proposal.id,
mutating = mutatingProposalId == proposal.id,
isConnected = isConnected,
operatorAdminScopeAvailable = operatorAdminScopeAvailable,
onInspect = {
viewModel.inspectSkillWorkshopProposal(proposalId = proposal.id, agentId = selectedAgentParam)
},
onAction = { action ->
pendingAction =
SkillWorkshopPendingAction(
action = action,
proposalId = proposal.id,
title = proposal.title,
)
},
)
}
}
}
}
}
private enum class SkillWorkshopProposalAction(
val label: NativeText,
val confirmationTitle: NativeText,
val confirmationBody: (String) -> String,
val perform: (MainViewModel, String, String?) -> Unit,
) {
Apply(
nativeText("Apply"),
nativeText("Apply proposal?"),
{ nativeString("This will apply \"\$proposalTitle\" and refresh Skill Workshop state from the gateway.", it) },
MainViewModel::applySkillWorkshopProposal,
),
Reject(
nativeText("Reject"),
nativeText("Reject proposal?"),
{ nativeString("This will reject \"\$proposalTitle\" and refresh Skill Workshop state from the gateway.", it) },
MainViewModel::rejectSkillWorkshopProposal,
),
Quarantine(
nativeText("Quarantine"),
nativeText("Quarantine proposal?"),
{ nativeString("This will quarantine \"\$proposalTitle\" and refresh Skill Workshop state from the gateway.", it) },
MainViewModel::quarantineSkillWorkshopProposal,
),
}
private data class SkillWorkshopPendingAction(
val action: SkillWorkshopProposalAction,
val proposalId: String,
val title: String,
)
@Composable
private fun SkillWorkshopActionConfirmDialog(
action: SkillWorkshopPendingAction,
onDismiss: () -> Unit,
onConfirm: () -> Unit,
) {
val dialogTitle = action.action.confirmationTitle.resolveNativeText()
val dialogBody = action.action.confirmationBody(action.title)
AppConfirmationDialog(
title = dialogTitle,
confirmLabel = action.action.label.resolveNativeText(),
onConfirm = onConfirm,
onDismiss = onDismiss,
text = {
Text(text = dialogBody)
},
)
}
@Composable
private fun SkillWorkshopControls(
agents: List<GatewayAgentSummary>,
defaultAgentId: String?,
selectedAgentId: String,
onAgentChange: (String) -> Unit,
statusFilter: String,
onStatusFilterChange: (String) -> Unit,
query: String,
onQueryChange: (String) -> Unit,
refreshing: Boolean,
isConnected: Boolean,
onRefresh: () -> Unit,
) {
ClawPanel(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
SkillWorkshopAgentMenu(
agents = agents,
defaultAgentId = defaultAgentId,
selectedAgentId = selectedAgentId,
onAgentChange = onAgentChange,
modifier = Modifier.weight(1f),
)
ClawSecondaryButton(
text = if (refreshing) nativeString("Refreshing") else nativeString("Refresh"),
onClick = onRefresh,
enabled = isConnected && !refreshing,
icon = Icons.Default.Refresh,
)
}
ClawSegmentedControl(
options = skillWorkshopFilters.map { (_, label) -> nativeString(label) },
selected = nativeString(skillWorkshopFilters.firstOrNull { it.first == statusFilter }?.second ?: "All"),
onSelect = { label ->
onStatusFilterChange(skillWorkshopFilters.firstOrNull { nativeString(it.second) == label }?.first ?: "all")
},
maxOptionsPerRow = 4,
)
ClawTextField(
value = query,
onValueChange = onQueryChange,
placeholder = nativeString("Search proposals"),
)
}
}
@Composable
private fun SkillWorkshopAgentMenu(
agents: List<GatewayAgentSummary>,
defaultAgentId: String?,
selectedAgentId: String,
onAgentChange: (String) -> Unit,
modifier: Modifier = Modifier,
) {
var expanded by remember { mutableStateOf(false) }
val selectableAgents = agents.selectableAgents()
val label =
skillWorkshopAgentLabel(
agents = selectableAgents,
defaultAgentId = defaultAgentId,
selectedAgentId = selectedAgentId,
)
Box(modifier = modifier) {
ClawSecondaryButton(
text = label,
onClick = { expanded = true },
icon = Icons.Default.ArrowDropDown,
modifier = Modifier.fillMaxWidth(),
enabled = selectableAgents.isNotEmpty(),
)
AppDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
DropdownMenuItem(
text = { Text(nativeString("Default agent")) },
onClick = {
expanded = false
onAgentChange("")
},
)
selectableAgents
.filter { agent -> agent.id.trim().isNotEmpty() && agent.id != defaultAgentId }
.sortedBy { it.name ?: it.id }
.forEach { agent ->
DropdownMenuItem(
text = { Text(agent.name?.takeIf { it.isNotBlank() } ?: agent.id) },
onClick = {
expanded = false
onAgentChange(agent.id)
},
)
}
}
}
}
@Composable
private fun SkillWorkshopProposalRow(
proposal: GatewaySkillWorkshopProposal,
selected: Boolean,
busy: Boolean,
onClick: () -> Unit,
) {
ClawPanel {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(9.dp),
) {
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(
text = proposal.title,
style = ClawTheme.type.body,
color = ClawTheme.colors.text,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = proposal.description ?: proposal.skillKey,
style = ClawTheme.type.caption,
color = ClawTheme.colors.textSubtle,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
Column(horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(4.dp)) {
ClawStatusPill(
text = skillWorkshopStatusLabel(if (busy) "loading" else proposal.status),
status = skillWorkshopStatusPill(proposal.status),
)
Text(
text = skillWorkshopDateLabel(proposal.updatedAt),
style = ClawTheme.type.caption,
color = ClawTheme.colors.textSubtle,
maxLines = 1,
)
}
}
ClawSecondaryButton(
text = if (selected) nativeString("Selected") else nativeString("Open"),
onClick = onClick,
modifier = Modifier.fillMaxWidth(),
enabled = !selected,
)
}
}
@Composable
private fun SkillWorkshopProposalDetail(
proposal: GatewaySkillWorkshopProposal,
inspecting: Boolean,
mutating: Boolean,
isConnected: Boolean,
operatorAdminScopeAvailable: Boolean,
onInspect: () -> Unit,
onAction: (SkillWorkshopProposalAction) -> Unit,
) {
val actionEnabled =
skillWorkshopProposalActionEnabled(
isConnected = isConnected,
operatorAdminScopeAvailable = operatorAdminScopeAvailable,
busy = inspecting || mutating,
status = proposal.status,
)
ClawPanel {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
text = proposal.title,
style = ClawTheme.type.title,
color = ClawTheme.colors.text,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Text(
text = proposal.skillKey,
style = ClawTheme.type.caption,
color = ClawTheme.colors.textSubtle,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
ClawStatusPill(
text = skillWorkshopStatusLabel(proposal.status),
status = skillWorkshopStatusPill(proposal.status),
)
}
proposal.description?.let { description ->
Text(text = description, style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
}
SettingsMetricPanel(
rows =
listOf(
SettingsMetric(nativeString("Kind"), proposal.kind),
SettingsMetric(nativeString("Updated"), skillWorkshopDateLabel(proposal.updatedAt)),
SettingsMetric(nativeString("Support Files"), proposal.supportFiles.size.toString()),
),
)
Text(
text = proposal.content ?: nativeString("Inspect this proposal to load its markdown."),
style = ClawTheme.type.body,
color = if (proposal.content == null) ClawTheme.colors.textSubtle else ClawTheme.colors.text,
)
if (!operatorAdminScopeAvailable) {
Text(
text = nativeString("Apply, reject, and quarantine require operator.admin scope. Reconnect with shared gateway auth or approve an operator.admin device scope upgrade to enable lifecycle actions."),
style = ClawTheme.type.caption,
color = ClawTheme.colors.warning,
)
}
if (proposal.supportFiles.isNotEmpty()) {
HorizontalDivider(color = ClawTheme.colors.border)
proposal.supportFiles.forEach { file ->
Text(
text = file.path,
style = ClawTheme.type.caption,
color = ClawTheme.colors.textMuted,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
Row(
modifier =
Modifier
.fillMaxWidth()
.semantics { contentDescription = nativeString("Skill Workshop inspect and apply actions") },
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
ClawSecondaryButton(
text = if (inspecting) nativeString("Inspecting") else nativeString("Inspect"),
onClick = onInspect,
enabled = isConnected && !inspecting && !mutating,
modifier = Modifier.weight(1f),
)
ClawPrimaryButton(
text = if (mutating) nativeString("Working") else nativeString("Apply"),
onClick = { onAction(SkillWorkshopProposalAction.Apply) },
enabled = actionEnabled,
modifier = Modifier.weight(1f),
)
}
Row(
modifier =
Modifier
.fillMaxWidth()
.semantics { contentDescription = nativeString("Skill Workshop reject and quarantine actions") },
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
listOf(SkillWorkshopProposalAction.Reject, SkillWorkshopProposalAction.Quarantine).forEach { action ->
ClawSecondaryButton(
text = action.label.resolveNativeText(),
onClick = { onAction(action) },
enabled = actionEnabled,
modifier = Modifier.weight(1f),
)
}
}
}
}
}
internal fun skillWorkshopVisibleProposals(
summary: GatewaySkillWorkshopSummary,
selectedAgentId: String?,
): List<GatewaySkillWorkshopProposal> =
when {
summary.agentId == skillWorkshopAgentScope(selectedAgentId) -> summary.proposals
else -> emptyList()
}
internal fun skillWorkshopAgentScope(agentId: String?): String = agentId?.trim().orEmpty()
internal fun skillWorkshopFilteredProposals(
proposals: List<GatewaySkillWorkshopProposal>,
statusFilter: String,
query: String,
): List<GatewaySkillWorkshopProposal> {
val normalizedQuery = query.trim().lowercase()
return proposals
.filter { proposal ->
if (!skillWorkshopStatusMatchesFilter(proposal.status, statusFilter)) return@filter false
if (normalizedQuery.isEmpty()) return@filter true
val pieces =
listOf(proposal.title, proposal.description.orEmpty(), proposal.skillName, proposal.skillKey)
val haystack = pieces.joinToString(" ").lowercase()
haystack.contains(normalizedQuery)
}.sortedByDescending { it.updatedAt }
}
internal fun skillWorkshopStatusMatchesFilter(
status: String,
filter: String,
): Boolean =
when (filter) {
"all" -> true
"held" -> status == "quarantined" || status == "stale"
else -> status == filter
}
internal fun skillWorkshopProposalActionEnabled(
isConnected: Boolean,
operatorAdminScopeAvailable: Boolean,
busy: Boolean,
status: String,
): Boolean = isConnected && operatorAdminScopeAvailable && !busy && status == "pending"
internal fun skillWorkshopStatusLabel(status: String): String =
when (status) {
"pending" -> nativeString("Pending")
"quarantined", "stale" -> nativeString("Held")
"applied" -> nativeString("Applied")
"rejected" -> nativeString("Rejected")
"loading" -> nativeString("Loading")
else -> status
}
private fun skillWorkshopAgentLabel(
agents: List<GatewayAgentSummary>,
defaultAgentId: String?,
selectedAgentId: String,
): String {
val selected = selectedAgentId.trim()
if (selected.isNotEmpty()) {
return agents.firstOrNull { it.id == selected }?.name?.takeIf { it.isNotBlank() } ?: selected
}
val defaultId = defaultAgentId?.trim().orEmpty()
if (defaultId.isNotEmpty()) {
return agents.firstOrNull { it.id == defaultId }?.name?.takeIf { it.isNotBlank() }
?: nativeString("Default agent")
}
return nativeString("Default agent")
}
private fun skillWorkshopStatusPill(status: String): ClawStatus =
when (status) {
"pending", "quarantined", "stale" -> ClawStatus.Warning
"applied" -> ClawStatus.Success
else -> ClawStatus.Neutral
}
private fun skillWorkshopDateLabel(value: String): String = value.trim().takeIf { it.isNotEmpty() }?.take(10) ?: nativeString("Unknown")

View file

@ -75,11 +75,6 @@ internal object ClawIcons {
"M6 13h2M6 9h2",
)
val SkillWorkshop: ImageVector by outline(
"Wrench",
"M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z",
)
val OpenClaw: ImageVector by lazy {
ImageVector
.Builder("OpenClaw", 24.dp, 24.dp, 120f, 120f)

View file

@ -11,7 +11,6 @@ import ai.openclaw.app.ui.chat.chatThinkingOptionLabel
import ai.openclaw.app.ui.formatApprovalDuration
import ai.openclaw.app.ui.formatCronWake
import ai.openclaw.app.ui.formatUsageUpdated
import ai.openclaw.app.ui.skillWorkshopStatusLabel
import android.content.res.Configuration
import android.os.Build
import androidx.appcompat.app.AppCompatActivity
@ -143,18 +142,10 @@ class AppLanguageTest {
setAppLanguage(AppLanguage.English)
assertEquals("Mic off", retained.value)
assertEquals("Mic off · raw", retainedComposite.value)
assertEquals("Pending", skillWorkshopStatusLabel("pending"))
setAppLanguage(AppLanguage.French)
assertEquals("Micro désactivé", retained.value)
assertEquals("Micro désactivé · raw", retainedComposite.value)
assertEquals("En attente", skillWorkshopStatusLabel("pending"))
assertEquals("Retenu", skillWorkshopStatusLabel("quarantined"))
assertEquals("Retenu", skillWorkshopStatusLabel("stale"))
assertEquals("Appliqué", skillWorkshopStatusLabel("applied"))
assertEquals("Rejeté", skillWorkshopStatusLabel("rejected"))
assertEquals("Chargement", skillWorkshopStatusLabel("loading"))
assertEquals("future_status", skillWorkshopStatusLabel("future_status"))
assertEquals("Élevé", chatThinkingOptionLabel(ChatThinkingLevelOption("high", "high")))
assertEquals("Adaptatif", chatThinkingOptionLabel(ChatThinkingLevelOption("adaptive", "adaptive")))
val androidRelease =

View file

@ -158,7 +158,7 @@ class SecurePrefsTest {
val legacyOrder = listOf("threads", "home", "skills", "work")
val storedOrder = prefs.sidebarPageOrder.value
assertEquals(legacyOrder, storedOrder.take(legacyOrder.size))
assertTrue("New workspace pages follow the saved order", "skill-workshop" in storedOrder.drop(legacyOrder.size))
assertTrue("New workspace pages follow the saved order", "dreaming" in storedOrder.drop(legacyOrder.size))
assertEquals(storedOrder.size, storedOrder.distinct().size)
assertEquals(storedOrder, testPrefs(context).sidebarPageOrder.value)
assertEquals(
@ -179,9 +179,9 @@ class SecurePrefsTest {
assertEquals(defaultSidebarVisiblePages, prefs.sidebarVisiblePages.value)
prefs.setSidebarVisiblePages(listOf("settings", "threads", "skill-workshop", "home", "threads", "unknown"))
prefs.setSidebarVisiblePages(listOf("settings", "threads", "dreaming", "home", "threads", "unknown"))
val expected = listOf("threads", "skill-workshop", "home")
val expected = listOf("threads", "dreaming", "home")
assertEquals(expected, prefs.sidebarVisiblePages.value)
assertEquals(expected, testPrefs(context).sidebarVisiblePages.value)
assertEquals(defaultSidebarVisiblePages, sanitizeSidebarVisiblePages(listOf("unknown")))

View file

@ -1,285 +0,0 @@
package ai.openclaw.app
import ai.openclaw.app.gateway.GatewayEndpoint
import ai.openclaw.app.i18n.NativeText
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
import java.lang.reflect.Field
import java.util.UUID
import java.util.concurrent.atomic.AtomicLong
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class SkillWorkshopAgentScopeRuntimeTest {
private val json = Json { ignoreUnknownKeys = true }
@Before
fun clearPlainPrefs() {
RuntimeEnvironment
.getApplication()
.getSharedPreferences("openclaw.node", android.content.Context.MODE_PRIVATE)
.edit()
.clear()
.commit()
}
@Test
fun gatewayActionsCarryClosedLocalizedPresentation() {
val cases =
listOf(
SkillWorkshopGatewayAction.Apply to Triple("apply", "applied", "Proposal applied."),
SkillWorkshopGatewayAction.Reject to Triple("reject", "rejected", "Proposal rejected."),
SkillWorkshopGatewayAction.Quarantine to Triple("quarantine", "quarantined", "Proposal quarantined."),
)
for ((action, expected) in cases) {
val (methodSuffix, expectedStatus, noticeSource) = expected
assertEquals(methodSuffix, action.methodSuffix)
assertEquals(expectedStatus, action.expectedStatus)
assertEquals(NativeText.Resource(noticeSource, emptyList()), action.notice)
assertEquals(NativeText.Resource(methodSuffix, emptyList()), action.verb)
assertEquals(
NativeText.Resource(
source = "Gateway returned status '\$statusLabel' after \${action.verb}.",
formatArgs = listOf(NativeText.Verbatim("future_status"), action.verb),
),
skillWorkshopUnexpectedStatusText("future_status", action),
)
assertEquals(
NativeText.Resource(
source = "Could not \${action.verb} Skill Workshop proposal.",
formatArgs = listOf(action.verb),
),
skillWorkshopActionFailureText(action),
)
}
}
@Test
fun missingGatewayActionStatusUsesLocalizedUnknownFallback() {
assertEquals(
NativeText.Resource(
source = "Gateway returned status '\$statusLabel' after \${action.verb}.",
formatArgs =
listOf(
NativeText.Resource(source = "unknown", formatArgs = emptyList()),
SkillWorkshopGatewayAction.Apply.verb,
),
),
skillWorkshopUnexpectedStatusText(null, SkillWorkshopGatewayAction.Apply),
)
}
@Test
fun resetSkillWorkshopAgentScopeClearsRowsAndInFlightActionState() {
val runtime = createTestRuntime()
seedConnectedRuntime(runtime)
runtime.resetSkillWorkshopAgentScope("main")
readField<MutableStateFlow<GatewaySkillWorkshopSummary>>(runtime, "_skillWorkshopSummary").value =
GatewaySkillWorkshopSummary(
agentId = "main",
proposals = listOf(skillWorkshopProposal("main-proposal")),
)
runtime.resetSkillWorkshopAgentScope("ops")
assertEquals("ops", runtime.skillWorkshopSummary.value.agentId)
assertEquals(emptyList<GatewaySkillWorkshopProposal>(), runtime.skillWorkshopSummary.value.proposals)
assertFalse(runtime.skillWorkshopRefreshing.value)
assertNull(runtime.skillWorkshopErrorText.value)
assertNull(runtime.skillWorkshopNoticeText.value)
assertNull(runtime.skillWorkshopInspectingProposalId.value)
assertNull(runtime.skillWorkshopMutatingProposalId.value)
}
@Test
fun inspectAndMutateDoNotStartForStaleSelectedAgentScope() {
val runtime = createTestRuntime()
seedConnectedRuntime(runtime)
runtime.resetSkillWorkshopAgentScope("main")
runtime.inspectSkillWorkshopProposal(proposalId = "ops-proposal", agentId = "ops")
readField<MutableStateFlow<List<String>>>(runtime, "_operatorScopes").value = listOf("operator.admin")
waitUntil { runtime.operatorAdminScopeAvailable.value }
runtime.applySkillWorkshopProposal(proposalId = "ops-proposal", agentId = "ops")
Thread.sleep(100)
assertEquals("main", runtime.skillWorkshopSummary.value.agentId)
assertNull(runtime.skillWorkshopInspectingProposalId.value)
assertNull(runtime.skillWorkshopMutatingProposalId.value)
assertNull(runtime.skillWorkshopErrorText.value)
assertNull(runtime.skillWorkshopNoticeText.value)
}
@Test
fun busyProposalActionDoesNotInvalidateActiveRequestGenerations() {
val runtime = createTestRuntime()
seedConnectedRuntime(runtime)
readField<MutableStateFlow<GatewaySkillWorkshopSummary>>(runtime, "_skillWorkshopSummary").value =
GatewaySkillWorkshopSummary(
agentId = "main",
proposals = listOf(skillWorkshopProposal("proposal-1")),
)
readField<MutableStateFlow<List<String>>>(runtime, "_operatorScopes").value = listOf("operator.admin")
waitUntil { runtime.operatorAdminScopeAvailable.value }
readField<MutableStateFlow<String?>>(runtime, "_skillWorkshopMutatingProposalId").value = "proposal-1"
val mutationSeq = readField<AtomicLong>(runtime, "skillWorkshopMutationSeq").apply { set(41) }
val inspectSeq = readField<AtomicLong>(runtime, "skillWorkshopInspectSeq").apply { set(17) }
runtime.applySkillWorkshopProposal(proposalId = "proposal-1", agentId = "main")
runtime.inspectSkillWorkshopProposal(proposalId = "proposal-1", agentId = "main")
Thread.sleep(100)
assertEquals(41, mutationSeq.get())
assertEquals(17, inspectSeq.get())
assertEquals("proposal-1", runtime.skillWorkshopMutatingProposalId.value)
assertNull(runtime.skillWorkshopInspectingProposalId.value)
}
@Test
fun proposalActionResultUsesGatewayReturnedStatusAndPreservesInspectedDetails() {
val runtime = createTestRuntime()
val supportFiles =
listOf(GatewaySkillWorkshopSupportFile(path = "references/proof.md", content = "proof"))
val previous =
skillWorkshopProposal("proposal-1")
.copy(
status = "pending",
content = "inspected markdown",
supportFiles = supportFiles,
)
val rejected =
parseSkillWorkshopActionResult(
runtime,
"""
{
"record": {
"id": "proposal-1",
"kind": "create",
"status": "rejected",
"title": "Rejected proposal",
"description": "Gateway action response",
"createdAt": "2026-07-08T00:00:00Z",
"updatedAt": "2026-07-09T00:00:00Z",
"scan": { "state": "clean" },
"target": { "skillName": "Rejected Skill", "skillKey": "rejected-skill" }
}
}
""".trimIndent(),
previous,
)
assertEquals("rejected", rejected?.status)
assertEquals("2026-07-09T00:00:00Z", rejected?.updatedAt)
assertEquals("Rejected Skill", rejected?.skillName)
assertEquals("clean", rejected?.scanState)
assertEquals("inspected markdown", rejected?.content)
assertEquals(supportFiles, rejected?.supportFiles)
}
private fun createTestRuntime(): NodeRuntime {
val app = RuntimeEnvironment.getApplication()
val securePrefs =
app.getSharedPreferences(
"openclaw.node.secure.test.${UUID.randomUUID()}",
android.content.Context.MODE_PRIVATE,
)
return NodeRuntime(app, SecurePrefs(app, securePrefsOverride = securePrefs))
}
private fun seedConnectedRuntime(runtime: NodeRuntime) {
writeField(runtime, "connectedEndpoint", GatewayEndpoint.manual("127.0.0.1", 18789))
writeField(runtime, "operatorConnected", true)
}
private fun skillWorkshopProposal(id: String): GatewaySkillWorkshopProposal =
GatewaySkillWorkshopProposal(
id = id,
status = "pending",
kind = "create",
title = "Proposal $id",
skillKey = id,
skillName = "Proposal $id",
description = "desc",
createdAt = "2026-07-08T00:00:00Z",
updatedAt = "2026-07-08T00:00:00Z",
scanState = null,
)
private fun parseSkillWorkshopActionResult(
runtime: NodeRuntime,
payloadJson: String,
previous: GatewaySkillWorkshopProposal,
): GatewaySkillWorkshopProposal? {
val method =
runtime.javaClass.getDeclaredMethod(
"parseSkillWorkshopProposalActionResult",
JsonObject::class.java,
GatewaySkillWorkshopProposal::class.java,
)
method.isAccessible = true
@Suppress("UNCHECKED_CAST")
return method.invoke(
runtime,
json.parseToJsonElement(payloadJson).jsonObject,
previous,
) as GatewaySkillWorkshopProposal?
}
private fun waitUntil(condition: () -> Boolean) {
repeat(50) {
if (condition()) return
Thread.sleep(10)
}
error("Expected condition to become true")
}
private fun writeField(
target: Any,
name: String,
value: Any?,
) {
var type: Class<*>? = target.javaClass
while (type != null) {
try {
val field: Field = type.getDeclaredField(name)
field.isAccessible = true
field.set(target, value)
return
} catch (_: NoSuchFieldException) {
type = type.superclass
}
}
error("Field $name not found on ${target.javaClass.name}")
}
private fun <T> readField(
target: Any,
name: String,
): T {
var type: Class<*>? = target.javaClass
while (type != null) {
try {
val field: Field = type.getDeclaredField(name)
field.isAccessible = true
@Suppress("UNCHECKED_CAST")
return field.get(target) as T
} catch (_: NoSuchFieldException) {
type = type.superclass
}
}
error("Field $name not found on ${target.javaClass.name}")
}
}

View file

@ -87,25 +87,25 @@ class CommandPaletteLogicTest {
@Test
fun workspacePageRemainsReachableThroughPagesMenuAndSearchWithBackToOrigin() {
val workshop = nativeString("Skill Workshop")
val workshopDescription = nativeString("Review generated skill proposals before they become live skills.")
val page = nativeString("Dreaming")
val pageDescription = nativeString("Memory consolidation and dream diary.")
withShell(HomeDestination.Connect) { backDispatcher, assertRuntimeUnchanged ->
composeRule.onNodeWithTag("sidebar-open-overview").performClick()
composeRule.onNodeWithTag("sidebar-pages-menu").performClick()
composeRule.onNodeWithText(workshop).performScrollTo().performClick()
composeRule.onNodeWithText(workshopDescription).assertIsDisplayed()
composeRule.onNodeWithText(page).performScrollTo().performClick()
composeRule.onNodeWithText(pageDescription).assertIsDisplayed()
assertRuntimeUnchanged()
composeRule.runOnIdle { backDispatcher.onBackPressed() }
composeRule.onNodeWithTag("sidebar-open-overview").assertIsDisplayed()
composeRule.onNodeWithContentDescription(nativeString("Search")).performClick()
composeRule.onNode(hasSetTextAction()).performTextReplacement(workshop)
composeRule.onNode(hasSetTextAction()).performTextReplacement(page)
val searchResults = hasScrollAction() and hasAnyDescendant(hasSetTextAction())
composeRule
.onNode(hasText(workshop) and hasClickAction() and hasSetTextAction().not() and hasAnyAncestor(searchResults))
.onNode(hasText(page) and hasClickAction() and hasSetTextAction().not() and hasAnyAncestor(searchResults))
.performScrollTo()
.performClick()
composeRule.onNodeWithText(workshopDescription).assertIsDisplayed()
composeRule.onNodeWithText(pageDescription).assertIsDisplayed()
composeRule.onNodeWithContentDescription(nativeString("Back")).performClick()
composeRule.onNodeWithTag("sidebar-open-overview").assertIsDisplayed()
assertRuntimeUnchanged()
@ -167,7 +167,7 @@ class CommandPaletteLogicTest {
assertEquals(providerSubtitle, categoryMatches.single { it.action == providerAction }.subtitle.resolveNativeText())
val workspaceMatches = commandItems(query = nativeString("Workspace"), desktopObserveAvailable = false, providerSubtitle = providerSubtitle)
assertTrue(workspaceMatches.any { it.action == CommandAction.Settings(SettingsRoute.CronJobs) })
assertTrue(workspaceMatches.any { it.action == CommandAction.Settings(SettingsRoute.SkillWorkshop) })
assertTrue(workspaceMatches.any { it.action == CommandAction.Settings(SettingsRoute.Dreaming) })
assertFalse(workspaceMatches.any { it.action == providerAction })
// These destinations are outside the main Settings row group but still own routes.

View file

@ -10,8 +10,6 @@ import ai.openclaw.app.GatewayNodeCapabilityApproval
import ai.openclaw.app.GatewayNodeSummary
import ai.openclaw.app.GatewayNodesDevicesSummary
import ai.openclaw.app.GatewayPendingDeviceSummary
import ai.openclaw.app.GatewaySkillWorkshopProposal
import ai.openclaw.app.GatewaySkillWorkshopSummary
import ai.openclaw.app.chat.ChatSessionEntry
import ai.openclaw.app.gatewayConnectionDisplay
import ai.openclaw.app.i18n.resolveNativeText
@ -273,96 +271,6 @@ class ShellScreenLogicTest {
assertEquals(emptyList<String>(), rows.map { it.title })
}
@Test
fun skillWorkshopFilteringMatchesHeldAndSearchText() {
val proposals =
listOf(
skillWorkshopProposal("pending", "pending", title = "Browser Playbook", skillKey = "browser-playbook"),
skillWorkshopProposal("stale", "stale", title = "Old Draft", skillKey = "old-draft"),
skillWorkshopProposal("quarantine", "quarantined", title = "Risky Skill", skillKey = "risky-skill"),
)
assertEquals(listOf("stale", "quarantine"), skillWorkshopFilteredProposals(proposals, "held", "").map { it.id })
assertEquals(listOf("pending"), skillWorkshopFilteredProposals(proposals, "all", "browser").map { it.id })
assertTrue(skillWorkshopStatusMatchesFilter("stale", "held"))
assertFalse(skillWorkshopStatusMatchesFilter("applied", "held"))
}
@Test
fun skillWorkshopStatusLabelsMapKnownCodesAndPreserveUnknownValues() {
assertEquals("Pending", skillWorkshopStatusLabel("pending"))
assertEquals("Held", skillWorkshopStatusLabel("quarantined"))
assertEquals("Held", skillWorkshopStatusLabel("stale"))
assertEquals("Applied", skillWorkshopStatusLabel("applied"))
assertEquals("Rejected", skillWorkshopStatusLabel("rejected"))
assertEquals("Loading", skillWorkshopStatusLabel("loading"))
assertEquals("future_status", skillWorkshopStatusLabel("future_status"))
}
@Test
fun skillWorkshopVisibleProposalsAreKeyedBySelectedAgentScope() {
val mainProposal = skillWorkshopProposal("main-proposal", "pending")
val opsProposal = skillWorkshopProposal("ops-proposal", "pending")
assertEquals(
listOf("main-proposal"),
skillWorkshopVisibleProposals(
GatewaySkillWorkshopSummary(agentId = "", proposals = listOf(mainProposal)),
selectedAgentId = null,
).map { it.id },
)
assertEquals(
emptyList<String>(),
skillWorkshopVisibleProposals(
GatewaySkillWorkshopSummary(agentId = "main", proposals = listOf(mainProposal)),
selectedAgentId = "ops",
).map { it.id },
)
assertEquals(
listOf("ops-proposal"),
skillWorkshopVisibleProposals(
GatewaySkillWorkshopSummary(agentId = "ops", proposals = listOf(opsProposal)),
selectedAgentId = " ops ",
).map { it.id },
)
}
@Test
fun skillWorkshopProposalActionsRequireAdminScope() {
assertTrue(
skillWorkshopProposalActionEnabled(
isConnected = true,
operatorAdminScopeAvailable = true,
busy = false,
status = "pending",
),
)
assertFalse(
skillWorkshopProposalActionEnabled(
isConnected = true,
operatorAdminScopeAvailable = false,
busy = false,
status = "pending",
),
)
assertFalse(
skillWorkshopProposalActionEnabled(
isConnected = true,
operatorAdminScopeAvailable = true,
busy = true,
status = "pending",
),
)
assertFalse(
skillWorkshopProposalActionEnabled(
isConnected = true,
operatorAdminScopeAvailable = true,
busy = false,
status = "applied",
),
)
}
@Test
fun operatorScopesNormalizeForStableAdminChecks() {
assertEquals(
@ -876,23 +784,4 @@ class ShellScreenLogicTest {
pauseReconnect = false,
retryable = false,
)
private fun skillWorkshopProposal(
id: String,
status: String,
title: String = id,
skillKey: String = id,
): GatewaySkillWorkshopProposal =
GatewaySkillWorkshopProposal(
id = id,
kind = "create",
status = status,
title = title,
description = null,
skillName = title,
skillKey = skillKey,
createdAt = "2026-07-08T00:00:00.000Z",
updatedAt = "2026-07-08T00:00:00.000Z",
scanState = null,
)
}

View file

@ -351,7 +351,7 @@ class SidebarCatalogGroupingTest {
composeRule.runOnIdle {
assertEquals(listOf(true, false), dragStates)
assertEquals(
listOf("home", "work", "agents", "skills", "threads", "automations", "usage", "skill-workshop", "dreaming", "terminal", "desktop"),
listOf("home", "work", "agents", "skills", "threads", "automations", "usage", "dreaming", "terminal", "desktop"),
prefs.sidebarPageOrder.value,
)
}
@ -377,7 +377,7 @@ class SidebarCatalogGroupingTest {
.performTouchInput(dragOnePageDown)
composeRule.runOnIdle {
assertEquals(
listOf("home", "agents", "work", "skills", "threads", "automations", "usage", "skill-workshop", "dreaming", "terminal", "desktop"),
listOf("home", "agents", "work", "skills", "threads", "automations", "usage", "dreaming", "terminal", "desktop"),
prefs.sidebarPageOrder.value,
)
assertEquals(listOf("agents", "home", "skills", "threads"), prefs.sidebarVisiblePages.value)

View file

@ -46,7 +46,7 @@ class SidebarShellLogicTest {
),
destinations.take(5),
)
assertTrue(SidebarDestination.SkillWorkshop in destinations.drop(5))
assertTrue(SidebarDestination.Dreaming in destinations.drop(5))
assertEquals(destinations.size, destinations.distinct().size)
}

View file

@ -1521,7 +1521,6 @@ src/agents/tools/sessions-search-tool.ts 1
src/agents/tools/sessions-spawn-tool.ts 5
src/agents/tools/sessions-tool.ts 1
src/agents/tools/sessions-yield-tool.ts 1
src/agents/tools/skill-workshop-tool-helpers.ts 1
src/agents/tools/structured-output-tool.ts 2
src/agents/tools/subagents-tool.ts 1
src/agents/tools/system-agent-tool.ts 1
@ -2609,9 +2608,6 @@ src/skills/loading/frontmatter.ts 1
src/skills/loading/workspace-skill-sync.runtime.ts 1
src/skills/runtime/remote-probe-utils.ts 2
src/skills/runtime/remote-skills.ts 1
src/skills/workshop/policy.ts 1
src/skills/workshop/store-record.ts 3
src/skills/workshop/store-sqlite-event.ts 1
src/skills/workshop/tool-policy-diagnostic.ts 1
src/snapshot/git-backup-codec.ts 7
src/snapshot/git-backup.ts 2
@ -2908,8 +2904,6 @@ ui/src/pages/portals/portal-reachability.ts 1
ui/src/pages/profile/identity-section.ts 2
ui/src/pages/secrets/view.ts 5
ui/src/pages/sessions/view.ts 7
ui/src/pages/skill-workshop/self-learning.ts 1
ui/src/pages/skill-workshop/view.ts 1
ui/src/pages/skills/view.ts 2
ui/src/pages/usage/view-details.ts 5
ui/src/pages/usage/view-overview.ts 2

View file

@ -204,7 +204,6 @@ packages/ai/src/providers/openai-completions.test.ts
packages/ai/src/providers/openai-responses-shared.test.ts
packages/ai/src/transports/anthropic-transport-stream.test.ts
packages/gateway-client/src/client.ts
packages/gateway-protocol/src/schema/agents-models-skills.ts
packages/markdown-core/src/ir.ts
packages/memory-host-sdk/src/host/session-files.ts
packages/tool-call-repair/src/stream-normalizer.ts
@ -553,7 +552,6 @@ src/security/audit-extra.sync.ts
src/security/audit.ts
src/shared/json-schema-defaults.ts
src/skills/lifecycle/clawhub.test.ts
src/skills/workshop/service.test.ts
src/snapshot/local-repository.test.ts
src/snapshot/local-repository.ts
src/state/openclaw-agent-db.test.ts

View file

@ -2299,7 +2299,6 @@
["src/agents/cli-runner/helpers.system-prompt.test.ts","../../tts/tts-settings.js","81788c2e4e5749722791d31983539afde68c65b0b716cd8c04b2ad0af131f811",1]
["src/agents/cli-runner/helpers.system-prompt.test.ts","../git-coauthor-prompt.js","90887f66a0179bfc38dad4d1e7e8f884c0b17991c9ad299a3ac3f8946474bbe0",1]
["src/agents/cli-runner/media-guard.test.ts","../bash-tools.exec-approval-request.js","17a7fae09b4bd8176a883e2be62a0d198d04b8fa90133581426401b98c43aff6",1]
["src/agents/cli-runner/prepare.rooted-execution.test.ts","../sandbox.js","f8508379f40cc45da58313a2edb79619346536d4e544174667cca59f0e784499",1]
["src/agents/cli-runner/prepare.test.ts","../../config/config.js","24ac4b71ec8afe6ce1365afa2e73e815b93cd06fa54f0b448956fa7f7c510783",1]
["src/agents/cli-runner/prepare.test.ts","../../plugins/hook-runner-global.js","ebf8cfdafc39d1a03c94c982099c4f6ad4bb3dd8033299ca74820bb1f00ce0e4",1]
["src/agents/cli-runner/prepare.test.ts","../../tts/tts-settings.js","81788c2e4e5749722791d31983539afde68c65b0b716cd8c04b2ad0af131f811",1]
@ -2307,7 +2306,6 @@
["src/agents/cli-runner/prepare.test.ts","../sandbox.js","9d16c909d44fee8b0a6f01f3cdb7eabf19dd2d3292b7215e239a55eb122ad203",1]
["src/agents/cli-runner/prepare.web-search.test.ts","../bash-tools.js","7e330d0731389dd6c639869b9d7e6f0b000699bb5b0bfb4c55874a24e1c105b6",1]
["src/agents/cli-runner/prepare.web-search.test.ts","../tools/gateway.js","1691b9251bdf21a1da4e1f820a511a6505c317ea8fb99066b6c00360e1ff9b40",1]
["src/agents/cli-runner/rooted-execution.test.ts","../sandbox.js","dea033beccf964a810ef82ffee5cb61616b16308947fc95eddd1b02029c80893",1]
["src/agents/cli-session-store.test.ts","./model-selection.js","c16be4a7bfe9a4c152cedc014045af71c6abc4ed84bb1a48843abc7df26b0d03",1]
["src/agents/code-mode-executor.test.ts","../plugins/code-mode-executor.js","c12f9311bf695d8b987e966cb2f9439aed7cdd20e72c9881d16d739952079d33",1]
["src/agents/code-mode-node.lifecycle.test.ts","../infra/runtime-worker-url.js","4423e746829ce417e7df67fc87bc03960ca9fbe2411bcdafe10fe9c808b8fa64",1]
@ -2316,7 +2314,6 @@
["src/agents/code-mode-swarm.lazy.test.ts","./code-mode-swarm.runtime.js","4c5df8df2f2d97e45e38783e3cfc90096eaffc765053c55aa42a6167e0a13186",1]
["src/agents/code-mode-swarm.lazy.test.ts","./subagents/registry/subagent-registry.js","efe17eea321152030b35223b03dbaba21d4362510340da188fcc175fd69cb62d",1]
["src/agents/code-mode-swarm.test.ts","./subagents/spawn/subagent-spawn.js","3a9a5926f454ac071d769c28cb3970e6ac5ee54e890f5ecda926b8575ad3298f",1]
["src/agents/code-mode.import-boundary.test.ts","../skills/workshop/service-query.js","bef13acfe87d1dda8fb1cfb24dc8a08d1c0abb36183fb20902372a399f1290bd",1]
["src/agents/code-mode.import-boundary.test.ts","./subagents/registry/subagent-registry.js","a9cb63aa92717f17b9621e0c467f064f27f454e227c451b88b2c57637389e201",1]
["src/agents/code-mode.import-boundary.test.ts","./tools/agents-wait-tool.js","06f05128b97ce315df365ea767e3ad665ae82cfe604b63668505c3dbe60b0850",1]
["src/agents/codex-mcp-config.test.ts","../infra/exec-approvals-store.js","5494cc9404b43d73dfeef6d6b53ff76d6ddb9d0bc7d215347ef82418a2700f0b",1]
@ -2816,7 +2813,6 @@
["src/agents/github-tool-identity.test.ts","../process/exec.js","6f01d6e791341bf8ff8d627d9841bb705010dbd9bac3174444a12281cb25cad3",1]
["src/agents/github-tool-identity.test.ts","./github-oauth-records.js","1c9bc36bb2ae3c818b6c0af21f8cbc0e7b1cea68f0b09dc2bcfd3cf455ea9f04",1]
["src/agents/github-tool-profile-cleanup.test.ts","./github-oauth-records.js","61b7b9db319a03c318828440061d8282c2a3d72248e62ff9878ffec5aa83ae94",1]
["src/agents/harness/agent-end-side-effects.test.ts","../../skills/workshop/experience-review-default.js","24af769fe90a4b16cf671d2e1c5c4db322a8aa7081ea61932060a94878ebf08b",1]
["src/agents/harness/agent-end-side-effects.test.ts","./lifecycle-hook-helpers.js","16d16cff63f168b08ce43eb08057d49bc03e018713f7bc98a73f3c61ef60f4ab",1]
["src/agents/harness/builtin-openclaw.test.ts","../embedded-agent-runner/run/attempt.js","e8a12ed96055114b67422388214eebe76347abb574dbc9c8e63f8631554eb6f2",1]
["src/agents/harness/builtin-openclaw.test.ts","../simple-completion-execution.js","9ed172637035eb8bc24253e5520f7b2a1b0dc4ec9fedff86972be23285113b1d",1]
@ -3590,9 +3586,6 @@
["src/agents/tools/sessions-spawn-tool.mocks.test-support.ts","../subagents/spawn/subagent-spawn.runtime.js","59663d4502964a14b3bd4bc4702e04fa29e1e7b92ef72d059999b58a1bfb9734",1]
["src/agents/tools/sessions.test.ts","../../sessions/session-participant-recording.js","241d4b18e482838e88c516473f7f41997da33dc32afd0ea3e33dac0092caae7d",1]
["src/agents/tools/sessions.test.ts","./sessions-send-tool.a2a.js","a317c2e95b7f90649b532a06c921c6d6eb7f13a8179360eaea3e8d03ea0f5e1e",1]
["src/agents/tools/skill-workshop-tool.availability.test.ts","../openclaw-plugin-tools.js","5f4cec3553f307631c831e2fb9157c52af96afde0a174251d4e53d04a9ac3697",1]
["src/agents/tools/skill-workshop-tool.evaluation.test.ts","../../plugins/hook-runner-global.js","11cefe0df5ac8ecda8c60abf96a2e1f6d5fbd468bbc3f0569bb22f245cc1164f",1]
["src/agents/tools/skill-workshop-tool.list.test.ts","../../skills/workshop/target-lock.js","8840438efc3d2ef6f248751fa7054f155ecb9e33edb1c69464dbbecf55e99a9a",1]
["src/agents/tools/subagents-tool.test.ts","../subagents/registry/subagent-control-scope.js","5f15bd92bb108db1abdc93396daa1494a34216c8e7f323e75dddcea702d5dee6",1]
["src/agents/tools/subagents-tool.test.ts","../subagents/registry/subagent-control.js","faf0ec325f9a372ea275d29293dc4c4ddb6dfeeb57ee077df6373d95b7515f8e",1]
["src/agents/tools/subagents-tool.test.ts","../subagents/registry/subagent-list.js","4269237b137a45deb7ae69d723dd78fa54c5b0d0114e9292c4ac2567b49f7c61",1]
@ -5166,9 +5159,6 @@
["src/cli/skills-cli.commands.test.ts","../skills/lifecycle/clawhub.js","c9a19f72cc1efba4854fcec398b7378853118fb859e5e2d5f44bceb08e47cf71",1]
["src/cli/skills-cli.commands.test.ts","../skills/lifecycle/source-install.js","df0ce1c04f484c430749be933372f77fad98a45db3d08578171cae987a98d23a",1]
["src/cli/skills-cli.commands.test.ts","./one-shot-exit.js","0f90b817161cda63805e4c2a6ef3b6156f4d91039c01232f7e05f16bfd004af1",1]
["src/cli/skills-cli.curator.test.ts","../config/config.js","3977cc5bfbc4f5677816dd4467aaa1d8f1c1dee7635da57b7d4020804f6d1841",1]
["src/cli/skills-cli.curator.test.ts","../gateway/call.js","2405ee18bc2b4fa6232d2cd0a002fabc0575a644165f8a61eb26ce12b803852f",1]
["src/cli/skills-cli.curator.test.ts","../infra/gateway-lock.js","71eef2c99af4283b23e796895269a612b721ba4422f79523a23e82bb0d555f64",1]
["src/cli/skills-cli.test.ts","openclaw/plugin-sdk/agent-sessions","548c67ecfcd1d666c41ef827b824edf897f6d8eb3464f76f6bf9ae23dffbded3",1]
["src/cli/skills-cli.verify.test.ts","../agents/agent-scope.js","b117793af3d676d859052beb892f058481587162d102cd2400f51961e3e24b01",1]
["src/cli/skills-cli.verify.test.ts","../config/config.js","f4448fa735e408442c07e554899b084f01ce1d51e6690718fc1983922cd5dbc0",1]
@ -5176,9 +5166,6 @@
["src/cli/skills-cli.verify.test.ts","../infra/clawhub-skills.js","22650f746cf121c1465529e0ada287c7fa52815bcabe992bb0e6abea3d9ccab1",1]
["src/cli/skills-cli.verify.test.ts","../runtime.js","c16762af0d3584b5012d69f7d3cde52553a84f822e49a786d2be054e90712da3",1]
["src/cli/skills-cli.verify.test.ts","./one-shot-exit.js","8f430f08b312eb1183ec570fd01634860c80dccfe3c672c92abd3fc7c7017c10",1]
["src/cli/skills-cli.workshop-cache.test.ts","../agents/agent-scope.js","604b33cdc7f1d5954035e9bc006189ace978204299720e39c4213233adf8357b",1]
["src/cli/skills-cli.workshop-cache.test.ts","../config/config.js","3977cc5bfbc4f5677816dd4467aaa1d8f1c1dee7635da57b7d4020804f6d1841",1]
["src/cli/skills-cli.workshop-cache.test.ts","../gateway/call.js","88600357596afd112a9f3ba5156d4915589ac655a6248e47202f1f3b7466e8bc",1]
["src/cli/skills-library-cli.test.ts","./gateway-rpc.runtime.js","dc9d2411497017aa35e2a7a3413a1e40c968980097ab7f6e347d940b8da0399e",1]
["src/cli/skills-library-input.test.ts","./gateway-rpc.js","6d6a59c174a65bf7d1586632d2a7f1ef671013708d76a08a4ba5fbb045c86376",1]
["src/cli/skills-search-cli.test.ts","../runtime.js","6f2065a804855b6327fec2a00c009de1c9eec2f59fc7272b94c3f7846306315b",1]
@ -7348,8 +7335,6 @@
["src/gateway/mcp-http.test.ts","../agents/node-exec-availability.js","67ced7d75c824807fcdc173f914d08f94b4d4e64635add13687a2413953ed9ad",1]
["src/gateway/mcp-http.test.ts","./tool-resolution.js","2f639cb0decf41151a4c75c17b19c85464bb7b5bafa0a512b1592f98b28b1e8a",1]
["src/gateway/mcp-http.web-search.test.ts","../agents/agent-tools.before-tool-call.js","c88c13076deab84767ed04af12524075e559aa15538ff18a6a693ff02c1014e8",1]
["src/gateway/mcp-http/rooted-execution.test.ts","../../plugins/hook-runner-global.js","1bbcc11318d7e6fdb669b7837445d214b2cf63654843a9ab7517c5a4373a66e9",1]
["src/gateway/mcp-http/rooted-tools.test.ts","../../agents/tools/gateway.js","4f8268c82aad8b0f6fe808a38b753edc7431413247f5675e3187c345defcf5a8",1]
["src/gateway/mcp-oauth-callback.test.ts","../agents/mcp-oauth-store.js","dc691449bee52ab522216dbfe4713bc21dbb681f1ba9b7e4ee90a7014e41f1fd",1]
["src/gateway/mcp-oauth-callback.test.ts","../agents/mcp-oauth.js","3c1bb3242b60222aa1a3bcd8c1275a1e3e98924a4f819347c01e201ffc7d0fdc",1]
["src/gateway/mcp-oauth-callback.test.ts","../state/openclaw-state-worker-context.js","56e282604a5b5d2b8a2b9b01c0189c1034a9f0cb65377a9b6f4052a0d73b1f78",1]
@ -7447,8 +7432,6 @@
["src/gateway/server-maintenance.telemetry.test.ts","../infra/telemetry.js","c2a0e43bc1fceb877dfcb882832d8eceb8d0d381ada53adc903f5ea8e242391d",1]
["src/gateway/server-maintenance.telemetry.test.ts","../media/store.js","b2b25bad1973aa63dba575502338cbec9710c7100860904b7241febe7578ae4e",1]
["src/gateway/server-maintenance.telemetry.test.ts","../plugin-state/plugin-state-worker-client.js","22c2abd5624a05e74784356ec104f4470083f888e1d853c9cb94fe44b0f09020",1]
["src/gateway/server-maintenance.telemetry.test.ts","../skills/workshop/store-sqlite-record.js","132b80ce02d91cdd489eeb5b8b27fe84017dac6db91c75846796a9b4b5dcd527",1]
["src/gateway/server-maintenance.telemetry.test.ts","../skills/workshop/workspace-skill-read.js","a821256a1b374d995861441afb7011618060b0a30a21fe0d8f3bb6f10d13a2c7",1]
["src/gateway/server-maintenance.telemetry.test.ts","./chat-abort.js","b025828acde25bc18c21cdf68bb814f994be7751e3565692acbf12d6a0e77975",1]
["src/gateway/server-maintenance.telemetry.test.ts","./server-methods/session-active-runs.js","4762dc0a9243193c540ed67870afe774e67df48a8534135b0136aede19aab590",1]
["src/gateway/server-maintenance.telemetry.test.ts","./server/health-state.js","9006fb3bf77d6e6473d8c2b0bdd92bdd1500930c32de8d151f40d27711583357",1]
@ -7820,7 +7803,6 @@
["src/gateway/server-methods/sessions.send-followup-status.test.ts","../session-utils.js","c991cfa1013d1fad11f6df188a5a4c7fb4c113531a5195760896a054568c0a75",1]
["src/gateway/server-methods/sessions.send-followup-status.test.ts","./chat-send-external-entry.js","f34a4d3f270b12cc95eb6753ca2a125206177a45fd8b161933daaa13a179149e",1]
["src/gateway/server-methods/sessions.send-yield-resume.test.ts","./chat-send-external-entry.js","ada44aa78f4d08227ff21aa7788e027794eb5d991187274cd179219eab61083b",1]
["src/gateway/server-methods/skills-curator.test.ts","../../skills/workshop/curator.js","935aa6334ed7c5a2c58a691fa878f895abc07166e7e57a96d4c989c31a7eea77",1]
["src/gateway/server-methods/skills-upload.test.ts","../../plugins/install-security-scan.js","eb888f0abaa57b42fa2dca7e31f92ea57356e75ded5810432d1897b7b9b10735",1]
["src/gateway/server-methods/skills.clawhub.test.ts","../../agents/agent-scope.js","41b7c23048edd822918d9a6e6e9dfd3354a6ce202bea02f8e77a5c40983b9a77",1]
["src/gateway/server-methods/skills.clawhub.test.ts","../../config/config.js","4df9f8727b72fce829834eea72a9efbf2967c9433b878d7da1cdca69740e367e",1]
@ -7831,14 +7813,6 @@
["src/gateway/server-methods/skills.clawhub.test.ts","../../skills/lifecycle/clawhub.js","35b083807cfc03410e0ee0226d4e582a6a2ec8fd33578b8987253e40a01367f6",1]
["src/gateway/server-methods/skills.clawhub.test.ts","../../skills/lifecycle/install.js","70f8a7b89e6ed726e73a8a1b444be93cb4a86a48936e08030276f331462500b0",1]
["src/gateway/server-methods/skills.install-upload-policy.test.ts","../../plugins/install-security-scan.js","8260033f1a8e51bdb882f810c8d9b607411e6a54a8010de0ecf58710816e3004",1]
["src/gateway/server-methods/skills.proposals.test.ts","../../agents/agent-scope.js","6e51410ffd93690a81947258dcb98cc84c4c68a6afcf4b1fc773412904457622",1]
["src/gateway/server-methods/skills.proposals.test.ts","../../config/config.js","7b4b6de0f3fc92e963040b8618c9175315bd951232592c6373673b09ce00372d",1]
["src/gateway/server-methods/skills.proposals.test.ts","../../infra/clawhub-skills.js","5c6c350ad84263b2dac06cf228bb802e69b83b8b0facda663ca60f2c969b3cd9",1]
["src/gateway/server-methods/skills.proposals.test.ts","../../skills/lifecycle/clawhub.js","835a04ac092a007bb2516c26b290fdfd4840671e205fe45307a4a961b05ca80b",1]
["src/gateway/server-methods/skills.proposals.test.ts","../../skills/lifecycle/install.js","f2891a3e2279834bd13566a65539a67076711ad9e78ec3b299a8381acbdfbfc8",1]
["src/gateway/server-methods/skills.proposals.test.ts","../../skills/lifecycle/upload-install.js","ecfde2a64fdce5b0e64744d3a930010f19104babe8003a87d70e7d18df0bc9da",1]
["src/gateway/server-methods/skills.proposals.test.ts","../../skills/security/clawhub-verdicts.js","c1fee0b68a0ba97e94f9338843e8aa80b560f3da4ed55f10a188752471e04fd1",1]
["src/gateway/server-methods/skills.proposals.test.ts","./chat-send-handler.js","c941fc433cf4ab6cb35a743c406cc1577d433a00dc6ca7183c51e961079ce18d",1]
["src/gateway/server-methods/skills.publisher-identity.test.ts","../../agents/agent-scope.js","b6a5a88b92f44ee253011bb7e37dd99c4d4da69f4aa2caa815fef40a39b5ac11",1]
["src/gateway/server-methods/skills.publisher-identity.test.ts","../../config/config.js","b3d1ec9dd3f7a4243541a42cdfb8a7dfe8a23104e792431c8d46a5072701366a",1]
["src/gateway/server-methods/skills.publisher-identity.test.ts","../../skills/lifecycle/install.js","f2891a3e2279834bd13566a65539a67076711ad9e78ec3b299a8381acbdfbfc8",1]
@ -9848,10 +9822,6 @@
["src/skills/runtime/session-snapshot.test.ts","../loading/workspace-skill-prompt.js","c26cc28752b5b5247baef0a5ca540f92bf17a9c022ced2bf0b775f346fcfd39b",1]
["src/skills/runtime/session-snapshot.test.ts","./refresh-state.js","2ee38c0b16913304704f47232c9f735f8faadc0e7b504480c5136d00fb816cee",1]
["src/skills/runtime/session-snapshot.test.ts","./refresh.js","46743217bc462d49d0ef2ed28dd581cf8c4f9485d96c896acc110cc1d2df0d6a",1]
["src/skills/workshop/collection-restore.test.ts","../lifecycle/skill-change-hook.js","a204b1a7a4c87b548e518df12b00dbf47c3de2f650cb341438543055a3c2d408",1]
["src/skills/workshop/experience-review.apply.test.ts","../../agents/embedded-agent.js","f90a67daca3c760996fffe642110504bb733c4705d83c44770cda58acf78695e",1]
["src/skills/workshop/service-evaluation.test.ts","../../plugins/hook-runner-global.js","7b2e4b2069647a977b79fe28f60408dc41197b8341fc93c80e2532d270ecf394",1]
["src/skills/workshop/service-lifecycle-hooks.test.ts","../../plugins/hook-runner-global.js","e931e71519abeecf3554fe64b110c7647ba3589f35cf6641f2c045a4d073a8d2",1]
["src/snapshot/local-repository.windows-acl-probe.test.ts","../process/exec.js","d6ef266d41c26ca79d6c5e5788c6a39504596ca03433571e5189474b4770a15c",1]
["src/state/backup-run-records.test.ts","../../packages/terminal-core/src/note.js","f5864cdeb859b12765491a2066778d233cade85ee0bc133481fa4580397b105b",1]
["src/state/onboarding-recommendations.worker-lifecycle.test.ts","./openclaw-state-read-worker.js","5f99f95d17588a8ba082399db09c21e9d60ce587c020f450e37703a64b6eddda",1]
@ -10411,7 +10381,6 @@
["ui/src/pages/sessions/sessions-page.scope-race.test.ts","../../components/confirm-dialog.ts","29010d4b48ecb6a8932036adcb5c2d910c9c76c242c0124481a10ac96470b5e9",1]
["ui/src/pages/sessions/sessions-page.test.ts","../../components/confirm-dialog.ts","29010d4b48ecb6a8932036adcb5c2d910c9c76c242c0124481a10ac96470b5e9",1]
["ui/src/pages/sessions/sessions-page.test.ts","../../components/input-dialog.ts","fbfe3fd11c857de65c62af1111b77bb349318f86081123667530c611cec07487",1]
["ui/src/pages/skill-workshop/proposals.test.ts","../../lib/skill-workshop/diff-worker.ts","9e47ffd855b5620fe43264a4c229286d7b98b383512e8f64ddb3fecb271be354",1]
["ui/src/pages/worktrees/worktrees-page.test.ts","../../components/confirm-dialog.ts","29010d4b48ecb6a8932036adcb5c2d910c9c76c242c0124481a10ac96470b5e9",1]
["ui/src/plugins/control-ui-runtime.test.ts","./control-ui-loader.ts","442f9fbfb3799fd4b230a2d2ed48ae4ecc67ee61f13c13b19007eb38d2ed3709",1]
["ui/src/test-helpers/app-sidebar.ts","../components/sidebar-attention.ts","3fd2562bf996958dad169dcaee8c9e073f98090f79687de0ba938af14b8982f8",1]

View file

@ -107,7 +107,6 @@ src/process/command-queue.publish-transaction.test.ts 3
src/process/spawn-broker/command-startup.test.ts 1
src/process/spawn-broker/startup.test.ts 1
src/process/supervisor/service-child-relay-host.awaited-output.test.ts 1
src/skills/workshop/experience-review.apply.test.ts 1
src/state/openclaw-database-preflight.lifecycle.test.ts 4
src/state/openclaw-state-lease-existing.test.ts 1
src/system-agent/setup-lifetime.test.ts 1

View file

@ -1,5 +1,5 @@
{
"core": 2495,
"core": 2493,
"channel": 3874,
"plugin": 4527
}

View file

@ -1,4 +1,4 @@
1fd344bb5942819d8e4a4b2cfc22b40c8e6db8fa2630ae9330775e00edd76c3d config-baseline.json
cedc5254457691642cb4d00ebdff5096e3732b53ef4a3862d56106c410243b20 config-baseline.core.json
7da93e3e09afe03f8b2f4a59e68ba1213c4f76aa4e6021aaf8ab531825578d82 config-baseline.json
81ce3d68adebc8403606d7c87caada27b450861072b08bb8747bb78e2c6f3e18 config-baseline.core.json
452cba5854c9b6727126974a6525b8c8c0a359d76ccbfa6f2ebf9da40274fc7d config-baseline.channel.json
1f1ecddd1eb972e4ec784e79e3f2676247b27faf807b1f4da734deb2fba9e0ba config-baseline.plugin.json

View file

@ -22,10 +22,9 @@ Every job carries exactly one payload kind, chosen by flag:
| Command | `--command <shell>` or `--command-argv <json>` | A shell/process on the Gateway host, no model call |
| Script | `--script <file\|->` | A headless code-mode script using the owning agent's tools |
System-owned monitor jobs are gateway-converged and cannot be created or edited through the CLI or API. The `heartbeat` kind creates one heartbeat monitor job per heartbeat-enabled agent (see [Heartbeat](/gateway/heartbeat)). The weekly Skill Workshop review is a normal isolated `agentTurn` job with a reserved declaration key. Both appear in `openclaw cron list`; use `--all` to include disabled rows.
The `skillCollectionReview` payload kind is not accepted. Stored rows that use it are replaced with the canonical review job.
System-owned monitor jobs are gateway-converged and cannot be created or edited through the CLI or API. The `heartbeat` kind creates one heartbeat monitor job per heartbeat-enabled agent (see [Heartbeat](/gateway/heartbeat)). Monitor jobs appear in `openclaw cron list`; use `--all` to include disabled rows.
Skill collection review runs every 7 days. It is enabled when `skills.workshop.autonomous.mode` is `auto`; `propose` and `off` keep the system-owned job disabled. In `auto` mode, a review stays disabled when every statically resolvable model candidate is known to lack rooted execution support. Its display name includes `no-rooted-runtime`; inspect disabled rows with `openclaw cron list --all --json`. A supported fallback keeps the job enabled. Stored session model/runtime preferences and unknown eligibility also keep it enabled, with final checks at execution time. The Gateway converges these jobs at startup and after config reload. Convergence clears the reason and restores auto-mode enablement when the configured chain becomes eligible or unknown. Scheduled reviews require automations. When `cron.enabled` is `false` or `OPENCLAW_SKIP_CRON=1`, the Gateway logs a startup warning and does not run scheduled reviews. There is no separate weekly Gateway timer.
The weekly Skill Workshop curator (declaration key `skill-collection-review:<agentId>`, and the older `skillCollectionReview` payload kind) is retired. The Gateway deletes those stored rows when it loads the cron store and creates no replacement; the declaration-key namespace stays reserved. Learned-skill cleanup now runs without a schedule; see [Unused-skill cleanup](/tools/skill-workshop#unused-skill-cleanup).
### Agent-turn options

View file

@ -126,16 +126,6 @@ catalog. Historical snapshot paths do not require cleanup or a session reset.
`core/doctor/local-audio-acceleration` reports the auto-selected local STT command, separate capable/requested/observed backend evidence, and fallback order without loading a speech model. It emits an informational finding, so include `--severity-min info` to display it.
`core/doctor/skill-workshop-relocation` distinguishes pending legacy collection
backup roots from roots preserved for review. Eligible proposals or backup roots
receive `openclaw doctor --fix` guidance, not a guarantee that every backup will
be retired. Preserved roots require manual review of workspace ownership, backup
manifests, and workspace migration blockers. If both kinds remain, Doctor reports
both next steps. Do not delete preserved backups to clear the warning.
The check uses the configured agent directories and actual filesystem paths even
when lint reads a private state snapshot. Correctly placed Workshop targets do
not need relocation merely because lint uses a temporary directory.
## Check selection
```bash

View file

@ -160,7 +160,7 @@ Its bounded summary lists observed refusals before derivative blocked steps;
the full receipt list retains the complete chain.
Doctor imports recognized legacy workspace setup files during preflight, before
Workshop migration accesses workspace state. An existing canonical SQLite setup record wins,
other migrations access workspace state. An existing canonical SQLite setup record wins,
including milestones that are absent in SQLite. Doctor does not replay stale
milestones over it. Before removing a validated setup file or interrupted claim,
Doctor preserves its exact bytes beside the original as
@ -172,11 +172,26 @@ run has no workspace setup migration to repeat. Invalid files and workspace
identity/version conflicts remain blocked for inspection.
Update rehearsals write only inside their copied state directory. Workspace
files are not copied by the rehearsal, so absolute paths retained in proposal,
rollback, and backup records remain read-only inventory. Doctor reports how many
legacy workspace files it left untouched; it does not retire their files or
proposal history. After the candidate is installed, the real Doctor runs the
normal import, archival, and relocation against the operator's state.
files are not copied by the rehearsal, so absolute paths retained in legacy
records remain read-only inventory. After the candidate is installed, the real
Doctor runs the normal import and archival against the operator's state.
Skill Workshop proposals were removed. Doctor exports each pending or
quarantined proposal draft, with its support files, to
`<state-dir>/agents/<agentId>/agent/workshop-skills/.archive/.retired-proposals/<proposal-id>/`,
then drops the proposal tables. Exported drafts are not live skills; to keep
one, ask the agent to save it with `/learn` so it goes through the normal
validated, versioned Workshop write.
Doctor keeps the proposal tables and files, with a recoverable warning, when no
configured agent owns a proposal, it cannot export a draft, or it finds a
half-finished apply it cannot safely undo. That is a deliberate holdback, not a
failed migration, and Doctor still exits successfully: follow the warning. For
an unowned draft, add its agent back to the config, or save the whole proposal
directory (draft and support files) with `/learn` and delete it; then rerun
`openclaw doctor --fix` to finish the retirement. The pre-apply contents of a
half-finished apply are in the `skill_workshop_proposal_rollbacks` table until
then.
Plugin migrations with declared files outside the copied state are deferred as
one plugin operation. Doctor leaves their files and pending markers intact and

View file

@ -204,7 +204,7 @@ openclaw [--dev] [--profile <name>] <command>
install
update
verify
workshop list|inspect|propose-create|propose-update|revise|apply|reject|quarantine
workshop list|changes|show|archive|restore
list
info
check

View file

@ -67,16 +67,11 @@ openclaw skills info <name> --agent <id>
openclaw skills check
openclaw skills check --agent <id>
openclaw skills check --json
openclaw skills curator status
openclaw skills curator status --json
openclaw skills workshop propose-create --name "qa-check" --description "QA checklist" --proposal ./PROPOSAL.md
openclaw skills workshop propose-update qa-check --proposal ./PROPOSAL.md
openclaw skills workshop list
openclaw skills workshop inspect <proposal-id>
openclaw skills workshop revise <proposal-id> --proposal ./PROPOSAL.md
openclaw skills workshop apply <proposal-id>
openclaw skills workshop reject <proposal-id> --reason "Not reusable"
openclaw skills workshop quarantine <proposal-id> --reason "Needs security review"
openclaw skills workshop changes --limit 20
openclaw skills workshop show <name>
openclaw skills workshop archive <name> --reason "Superseded"
openclaw skills workshop restore <name>
```
`search`, `update`, and `verify` use ClawHub directly. `install @owner/<slug>`
@ -111,8 +106,7 @@ characters.
`info` resolves an exact skill name before a metadata key. Key, case-insensitive,
and separator-normalized matches must identify one skill; ambiguous selectors
fail instead of choosing discovery order. Workshop reads and update targeting
use the same lookup.
fail instead of choosing discovery order.
`check` separates **inventory**, **readiness**, and **visibility**:
@ -132,10 +126,10 @@ in inventory but not currently eligible or visible to the agent; install or
configure the reported prerequisite before expecting the model to use it
successfully.
Curator `status`, `pin`, `unpin`, and `restore`, plus Workshop `apply`, preserve
the same target boundary. They never read or mutate client-local state after an
explicitly selected Gateway fails; intentional offline behavior remains
available only for an implicitly selected local Gateway.
Workshop commands preserve the same target boundary. They never read or mutate
client-local state after an explicitly selected Gateway fails; intentional
offline behavior remains available only for an implicitly selected local
Gateway.
Git and local directory installs expect `SKILL.md` at the source root. The
install slug comes from `SKILL.md` frontmatter `name` when it is valid, then
@ -185,24 +179,8 @@ Notes:
| Skill Card fingerprint | Installed ClawHub bundles can include a generated `skill-card.md`. OpenClaw treats verification as a ClawHub server decision and does not reject an installed skill just because that generated card changes the bundle fingerprint. |
| `check --agent <id>` | Checks the selected agent's workspace and reports which ready skills are actually visible to that agent's prompt or command surface. |
| `workshop --agent <id>` | Accepted before or after a Workshop leaf command, for example `workshop --agent <id> list` or `workshop list --agent <id>`. If both are provided, the leaf value wins. |
| `curator --json` | Accepted before or after a Curator leaf command, for example `curator --json status` or `curator status --json`. |
| `list` | Default action when no subcommand is provided. |
| `list`/`info`/`check` output | Rendered output goes to stdout. With `--json`, the machine-readable payload stays on stdout for pipes and scripts. |
| `curator status --json` | Reports live Workshop skill usage recorded from trusted `skill.used` events, collection review outcomes per agent, and experience review outcomes per agent and workspace. |
| `curator pin`/`unpin`/`restore` | Retired commands remain registered but return an error explaining that weekly collection review manages the skill collection. |
### Workshop inventory and upgrades
`openclaw skills curator status` requests current Workshop inventory from the
selected Gateway. With a compatible Gateway, JSON includes
`"inventory": "live-workshop"`. Local status uses the current configuration too.
Missing usage displays as `not recorded`, not proof that a skill was never used.
An older Gateway can return an unmarked legacy response. The CLI accepts it
without switching to local state and prints a limited-coverage notice in text
output. JSON preserves the absence of the marker. See
[Workshop inventory and usage](/tools/skill-workshop/reference#workshop-inventory-and-usage)
for membership, tracking limits, unknown dates, and upgrade behavior.
On servers supporting full scanner reports, verification JSON includes `security.scannerReports.aig` (the full upstream SARIF report)
and `security.scannerReports.skillspector` (the full upstream JSON report) when ClawHub
@ -353,32 +331,32 @@ existing session. Removal preserves already selected revisions. See
## Skill Workshop
`openclaw skills workshop` manages pending skill proposals for the selected
agent. Proposals are not active skills until applied. For proposal
storage, support-file safeguards, Gateway methods, and approval policy, see
`openclaw skills workshop` inspects and manages the learned (Workshop) skills
of the selected agent. Agents create and update these skills themselves; every
change applies immediately and saves the previous version first. See
[Skill Workshop](/tools/skill-workshop).
```bash
openclaw skills workshop propose-create \
--name "qa-check" \
--description "Repeatable QA checklist" \
--proposal ./PROPOSAL.md
openclaw skills workshop propose-create \
--name "qa-check" \
--description "Repeatable QA checklist" \
--proposal-dir ./qa-check-proposal
openclaw skills workshop propose-update qa-check --proposal ./PROPOSAL.md
openclaw skills workshop list
openclaw skills workshop inspect <proposal-id>
openclaw skills workshop revise <proposal-id> --proposal ./PROPOSAL.md
openclaw skills workshop apply <proposal-id>
openclaw skills workshop reject <proposal-id> --reason "Duplicate"
openclaw skills workshop quarantine <proposal-id> --reason "Needs security review"
openclaw skills workshop changes --limit 20
openclaw skills workshop show release-notes
openclaw skills workshop show release-notes --file references/checklist.md
openclaw skills workshop show release-notes --version <version-id>
openclaw skills workshop archive release-notes --reason "Superseded by deploy-notes"
openclaw skills workshop restore release-notes
openclaw skills workshop restore release-notes --version <version-id>
```
`propose-create`, `propose-update`, and `revise` also accept `--goal <text>`
and `--evidence <text>` to record the proposal's motivation and supporting
notes alongside the `--proposal`/`--proposal-dir` content.
| Command | Description |
| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `list` | Lists live learned skills with usage counts, plus archived skills. Shows the autonomous mode and skills root. |
| `changes [--limit <n>]` | Shows recent changes, newest first: who made them (`agent`, `review`, `curator`, `user`), the action, and the saved version id. |
| `show <name> [--file <path>] [--version <id>]` | Prints `SKILL.md` or one support file, from the live skill or a saved version. |
| `archive <name> [--reason <text>]` | Hides the skill from its agent. The skill stays restorable. |
| `restore <name> [--version <id>]` | Restores the newest saved version, or the named one. The current live copy is saved first, so restore is undoable. |
Every Workshop command accepts `--agent <id>` and `--json`. `archive` and
`restore` act as `user` in the change feed.
## Related

View file

@ -858,6 +858,34 @@
"source": "/tools/skills-workshop",
"destination": "/tools/skill-workshop"
},
{
"source": "/tools/skill-workshop/how-it-works",
"destination": "/tools/skill-workshop"
},
{
"source": "/tools/skill-workshop/collection-review",
"destination": "/tools/skill-workshop#unused-skill-cleanup"
},
{
"source": "/tools/skill-workshop/authoring",
"destination": "/tools/skill-workshop#learn"
},
{
"source": "/tools/skill-workshop/proposals",
"destination": "/tools/skill-workshop#upgrading-from-earlier-releases"
},
{
"source": "/tools/skill-workshop/configuration",
"destination": "/tools/skill-workshop#configuration"
},
{
"source": "/tools/skill-workshop/reference",
"destination": "/tools/skill-workshop#operator-surfaces"
},
{
"source": "/tools/skill-workshop/troubleshooting",
"destination": "/tools/skill-workshop#troubleshooting"
},
{
"source": "/skills-config",
"destination": "/tools/skills-config"
@ -1886,19 +1914,7 @@
"tools/skills",
"tools/custodian-skills",
"tools/skill-workshop",
{
"group": "Skill Workshop",
"pages": [
"tools/skill-workshop/personal-library",
"tools/skill-workshop/how-it-works",
"tools/skill-workshop/collection-review",
"tools/skill-workshop/authoring",
"tools/skill-workshop/proposals",
"tools/skill-workshop/configuration",
"tools/skill-workshop/reference",
"tools/skill-workshop/troubleshooting"
]
},
"tools/skill-workshop/personal-library",
"tools/self-learning",
"tools/creating-skills",
"tools/skills-config",

View file

@ -174,7 +174,7 @@ See [MCP](/cli/mcp#openclaw-as-an-mcp-client-registry) and
- `entries.<skillKey>.enabled: false` disables a skill even if bundled/installed.
- `entries.<skillKey>.apiKey`: convenience for skills declaring a primary env var (plaintext string or SecretRef object).
- `limits.maxCandidatesPerRoot`, `limits.maxSkillsLoadedPerSource`, `limits.maxSkillsInPrompt`, `limits.maxSkillsPromptChars`, `limits.maxSkillFileBytes`: bound skill discovery and the model-facing skills prompt.
- Skill Workshop autonomy/approval settings (`workshop.autonomous.mode`, `workshop.approvalPolicy`, `workshop.maxPending`, `workshop.maxSkillBytes`) are documented in [Skills configuration](/tools/skills-config).
- Skill Workshop settings (`workshop.autonomous.mode`, `workshop.maxSkillBytes`) are documented in [Skills configuration](/tools/skills-config).
---

View file

@ -957,6 +957,7 @@ against the current SQLite owners before the import can rename profiles.
| `session.maintenance.rotateBytes` | removed (deprecated) |
| Runtime and channel tuning knobs retired in 2026.7 | removed (built-in production defaults apply) |
| `diagnostics.memoryPressureSnapshot`, legacy `diagnostics.memoryPressureBundle` | removed (automatic critical-memory snapshots were retired; no replacement automatic capture) |
| `skills.workshop.autonomous.mode: "propose"`, `skills.workshop.approvalPolicy`, `skills.workshop.maxPending` | `"off"`; proposal settings removed (Skill Workshop proposals were retired) |
Doctor migrates MCP `type: "http"` to `transport: "streamable-http"` and `type: "sse"` to `transport: "sse"` in both server maps. An existing `transport` wins. For command-based servers, Doctor removes `type: "stdio"`; the command still selects stdio. The update-time Doctor pass uses the same backed-up config repair. Plugin bundle files keep their external `type` format: bundle loading translates recognized types, and CLI exports use the destination's required format. An unknown bundle HTTP transport is rejected instead of being treated as SSE; its original `type` remains available to the destination CLI.

View file

@ -26,7 +26,7 @@ auth health, sandbox images, and plugin installs.
The JSON plugin index at `plugins/installs.json` is retired. Doctor leaves it unchanged and directs operators to run `openclaw doctor --fix` on `2026.9.5` with a pre-update backup. Updates also check this original file before stopping the running Gateway. Current SQLite plugin indexes and install records remain supported.
Legacy Skill Workshop proposal imports, skill relocation, and collection-backup repair belong to Doctor, including the repair pass during `openclaw update`. Normal Gateway and local CLI startup leave those artifacts untouched and do not discover Workshop backup roots or scan proposals for repair. Run `openclaw doctor --fix` or `openclaw doctor --yes` against the same state and config to complete a legacy Workshop migration. Plain Doctor can report remaining Workshop artifacts with repair guidance.
Retired Skill Workshop proposals belong to Doctor, including the repair pass during `openclaw update`. Doctor exports each pending or quarantined draft to `<agentDir>/workshop-skills/.archive/.retired-proposals/<proposal-id>/`, drops the retired proposal tables, and removes `<state-dir>/skill-workshop/proposals/`. Normal Gateway and local CLI startup leave those artifacts untouched. A failed export keeps the tables and files and reports a warning; rerun `openclaw doctor --fix` after fixing the cause.
A rewritten or truncated legacy audit raw archive does not stop Doctor or update finalization. Doctor quarantines it beside itself, preserves the sanitized archive and existing SQLite records, and reports the quarantined path once. Later repairs continue. See [legacy audit recovery](/cli/update/repair-and-recovery#skipped-legacy-audit-recovery) for retained backups and recovery limits.

View file

@ -352,20 +352,6 @@ Runtime caps such as cron `toolsAllow` are normalized and group-expanded by
OpenClaw before this contract is built. Native tools are disabled, and a
backend without a complete declared enforcement path fails before execution.
Rooted runs such as [Skill Workshop reviews](/tools/skill-workshop) also require
`isolatesInstructionsWithExactTools: true` on the backend registration. Declare
this optional capability only when exact-tool execution suppresses ambient
instruction files, skills, hooks, and plugins for both fresh and resumed runs.
The host-prepared instruction snapshot must remain authoritative. OpenClaw
rejects rooted runs when this declaration is absent, even if the backend can
enforce exact tools. Existing non-rooted runs do not require this field.
The bundled Claude CLI backend declares this capability. Rooted execution
disables its native tools and serves the selected OpenClaw tools through the
host-owned MCP grant, which retains the root, filesystem policy, and configured
sandbox. The declaration does not grant filesystem or approval authority to
the backend.
A backend whose native tools are model-callable may declare
`projectNativeToolAuthority(nativeTools)` so that automations created from its
sessions keep the creator's native capabilities. For Claude stream-JSON, the

View file

@ -231,7 +231,7 @@ resolve here.
- <a id="registration-and-execution"></a>[Registration and execution](/plugins/hooks/reference#registration-and-execution)
- <a id="hook-catalog"></a>[Hook catalog](/plugins/hooks/reference#hook-catalog)
- <a id="skill-lifecycle-and-evaluation"></a>[Skill lifecycle and evaluation](/plugins/hooks/reference#skill-lifecycle-and-evaluation)
- <a id="skill-lifecycle-and-evaluation"></a>[Skill lifecycle](/plugins/hooks/reference#skill-lifecycle)
- <a id="channel-pairing-requests"></a>[Channel pairing requests](/plugins/hooks/reference#channel-pairing-requests)
### Tool call policy hooks

View file

@ -3,7 +3,7 @@ summary: "How plugin hooks register and execute, plus the complete typed hook ca
read_when:
- You are wiring a typed handler and need the ordering, timeout, and failure rules
- You want to know which typed hook exists for a surface
- You are registering a Skill Workshop evaluator or a pairing observer
- You are registering a skill lifecycle or pairing observer
title: "Hook reference"
sidebarTitle: "Hook reference"
---
@ -92,10 +92,9 @@ The standard runner applies these defaults **per handler**:
| -------------------------------------------------------------------------------------------------------------- | ----------------------------------- | ---------------------------------------------------------------- |
| `before_agent_run`, `before_tool_call`, `before_install` | 15 seconds | Fail closed: block the run, tool call, or install |
| `before_agent_finalize`, `before_prompt_build`, `message_sending`, `reply_payload_sending`, `resolve_exec_env` | 15 seconds | Log and skip the failed handler; retain other successful results |
| `agent_end`, `before_compaction`, `after_compaction`, `skill_changed`, `skill_proposal_changed` | 30 seconds | Log and continue |
| `agent_end`, `before_compaction`, `after_compaction`, `skill_changed` | 30 seconds | Log and continue |
| `channel_pairing_requested` | 2 seconds | Log and continue |
| `gateway_stop` | 5 seconds | Log and continue shutdown |
| `skill_proposal_evaluate` | 120 seconds | Record an attributed error outcome |
| Other asynchronous hooks, including claim hooks | No runner timeout unless configured | Log and continue |
| `tool_result_persist`, `before_message_write` | No asynchronous timeout | Synchronous errors are logged; failed results are ignored |
@ -242,69 +241,14 @@ For `sessions.create` calls with `parentSessionKey` and `emitCommandHooks: true`
| `cron_reconciled` | Observe | Reconcile against the complete Gateway cron state after startup or reload |
| `cron_changed` | Observe | Observe Gateway-owned cron lifecycle changes (added, updated, removed, started, finished, scheduled) |
| `before_install` | Modify / gate | Inspect staged skill or plugin install material from a loaded plugin runtime |
| `skill_proposal_evaluate` | Evaluate | Evaluate one exact Skill Workshop draft and return attributed findings, metrics, or a decision |
| `skill_proposal_changed` | Observe | Observe durable Skill Workshop proposal lifecycle events after they commit |
| `skill_changed` | Observe | Observe committed live-skill create, update, and removal events |
### Skill lifecycle and evaluation
### Skill lifecycle
Use `skill_proposal_evaluate` for static analyzers, security scanners,
benchmarks, model-based graders, or other third-party evaluators. OpenClaw
passes an immutable candidate bundle with file hashes and a tree hash. Update
proposals also include the complete current skill as `baseline`. Text files use
UTF-8 content; binary files use base64.
Evaluator registrations run concurrently. Give each evaluator a stable
`registrationId`:
```typescript
api.on(
"skill_proposal_evaluate",
async (event) => {
const score = await evaluateBundle(event.candidate, event.baseline);
return {
evaluatorVersion: "rules-2026-07",
mode: "baseline-comparison",
decision: score.regressed ? "revise" : "pass",
summary: score.summary,
metrics: score.metrics,
findings: score.findings,
};
},
{ registrationId: "quality-regression", timeoutMs: 90_000 },
);
```
When evaluation input includes `correlationId`, OpenClaw forwards it to the
evaluator event for both manual and apply-triggered evaluations. This value is
caller-supplied correlation metadata, not authenticated identity or proof of
authorization. An authorization plugin must mint or replace the value through
a trusted entry point, bind it to the intended operation, and validate and
consume it itself.
Stored outcomes identify the evaluator, plugin id, plugin package version,
status, and returned result. Timeouts and thrown errors are recorded as
attributed error outcomes; they do not fail the whole evaluation. Among
evaluator outcomes, only a completed `decision: "block"` vetoes apply. Other
Workshop validation and ownership checks still apply. Apply revalidates the
evaluated target tree under the Workshop mutation lock, so any live skill asset
drift requires reevaluation.
The complete persisted evaluation envelope is capped at 512 KiB.
`skill_proposal_changed` fires after the matching proposal row and append-only
lifecycle event commit. It carries the event id, sequence, exact proposal
revision hash, optional correlation id, and evaluation outcomes.
`skill_changed` fires after a live skill create, update, or removal commits and
includes optional before/after artifacts with content and tree hashes, plus
declared and source versions when available.
These hooks are primitives, not an optimization scheduler. A plugin or external
controller can observe a durable proposal event, evaluate its exact revision hash,
revise with that hash and a correlation id, then repeat. OpenClaw does not
automatically revise proposals or run an unbounded evaluation loop.
Event replay is byte-bounded and returns `nextSequence` when another page is
available.
### Channel pairing requests
Use `channel_pairing_requested` when a plugin needs to notify an operator or

View file

@ -97,8 +97,3 @@ The method is optional for existing SDK implementations; automatic one-shot
recovery treats a missing method as uncertain cleanup. A native facade that owns
no transport may resolve immediately when its enclosing runtime separately owns
and verifies the process lifetime.
Harnesses that forward embedded attempt params should pass
`skillWorkshopProposalOnly` through. Proposal-only skill-workshop runs are
deliberately narrow single-tool runs, and the runtime keeps them on the raw
tool surface instead of engaging code mode or a tool-search catalog.

View file

@ -75,6 +75,25 @@ api.on("gateway_stop", async (event, ctx) => {
});
```
### Skill Workshop proposal hooks
The `skill_proposal_evaluate` and `skill_proposal_changed` hooks were removed
together with Skill Workshop proposals. Workshop now applies each change
immediately and keeps a restorable version, so there is no pending draft to
evaluate and no proposal lifecycle to observe. The hook runner methods
`runSkillProposalEvaluate` and `runSkillProposalChanged` were removed, and
`openclaw/plugin-sdk/plugin-entry` no longer exports
`PluginHookSkillProposalEvaluateEvent`, `PluginHookSkillProposalEvaluateResult`,
`PluginHookSkillProposalEvaluationOutcome`, `PluginHookSkillProposalChangedEvent`,
`PluginHookSkillProposalKind`, `PluginHookSkillEvaluationFinding`,
`PluginHookSkillBundleFile`, or `PluginHookSkillBundleSnapshot`. The optional
`proposal` field on `PluginHookSkillChangedEvent` was removed too.
To observe committed Workshop skill writes, register `skill_changed` and filter
on `source: "workshop"`. There is no replacement for pre-apply evaluation.
Registering a removed hook name logs an `unknown typed hook` warning and the
handler never runs.
### Private testing barrel
`openclaw/plugin-sdk/testing` was repo-local and excluded from shipped package

View file

@ -78,6 +78,34 @@ remove this delivery fence: older runtimes must refuse migrated state. Restoring
a pre-upgrade backup loses later receipt facts and does not undo external sends;
reconcile those effects before retrying an automation.
### Skill Workshop proposal retirement (state schema 20, same version)
Skill Workshop no longer stores proposals. The optional `skill_workshop_proposals`,
`skill_workshop_proposal_events`, `skill_workshop_proposal_rollbacks`, and
`skill_workshop_collection_reviews` tables leave the canonical schema without a
version bump; they were lazy additive tables, so older same-version readers
already tolerated their absence. Opening a database that still has them is
unchanged: the tables are no longer validated, and a runtime open never drops
them.
`openclaw doctor --fix`, including the Doctor run that `openclaw update` starts,
first repairs the known malformed
`idx_skill_workshop_collection_reviews_workspace_time` index during its schema
prelude. Its final Workshop step then writes every `pending` or `quarantined`
proposal draft, plus its support files, to
`<agentDir>/workshop-skills/.archive/.retired-proposals/<proposalId>/SKILL.md`
for the recorded owner agent, or the default agent when none was recorded.
Pre-SQLite bundles under `<state-dir>/skill-workshop/proposals/<id>/` with a
`proposal.json` are exported the same way. An existing export is never
overwritten. After every export succeeds, Doctor drops the four tables and their
indexes in one write transaction, then removes the legacy proposal files. Any
failure keeps the tables and files and records a warning; rerunning Doctor
resumes. Applied, rejected, and stale proposals, event history, rollback
metadata, and collection-review outcomes are discarded.
Downgrading to a build that still reads proposals needs the pre-upgrade backup;
the exported drafts remain plain files that can be recreated as skills.
### State schema 19
Schema 19 adds nullable `authorization_id TEXT` and
@ -194,8 +222,8 @@ absent. Per-agent schemas and native companion tables do not change; native
clients can continue validating and reading their existing owned tables at
state schema 17 without performing migrations.
Startup and `openclaw doctor --fix` apply the schema-16 Skill Workshop migration
before the prepared-worker migration when opening a schema-15 database. A
Startup and `openclaw doctor --fix` apply the prepared-worker migration when
opening a schema-15 or schema-16 database. A
schema-16 database receives only the prepared-worker migration. The tuple
constraint belongs to the last added column, so migration scans existing
environments once and preserves the table instead of rebuilding it. Leases,
@ -207,8 +235,7 @@ database changes from that attempt. Both published markers normally advance to
17 in that transaction. When an older updater still owns trailing ledger reads,
the existing [version-publication deferral](/reference/database-schemas/versioning#schema-bumps-and-older-updaters)
keeps the published markers at their earlier version and records applied content
17 separately. Reopening does not repeat that content migration. Workshop
directory relocation runs afterward, outside the schema transaction.
17 separately. Reopening does not repeat that content migration.
Before upgrading, stop older writers and create a verified, WAL-aware backup.
Builds supporting state schema 16 or earlier refuse the published schema 17.
@ -227,6 +254,10 @@ history and adds `owner_agent_id` plus its owner/time index. Proposal rows remai
collection review had released becomes `stale` with a status reason, so the
skill path it once created stays user-owned and Doctor never relocates it.
The same-version proposal retirement above removes this migration. A schema-15
database keeps its legacy Workshop columns until Doctor exports its pending
drafts and drops the proposal tables.
Skill Workshop ownership is now the physical
`<state-dir>/agents/<agentId>/agent/workshop-skills` directory. Startup and `openclaw doctor --fix`
drop the retired columns and index in the shared schema transaction. Both then

View file

@ -2108,19 +2108,11 @@ Process identity caches retain their existing database-path and identity-key
scope; warm cached values need no database operation. Schemas and update behavior
are unchanged; no migration or operator action is required.
Skill Workshop proposal reads, publication, evaluation, rollback metadata, and
status transitions execute in the existing shared-state worker. Record and event
writes remain one synchronous transaction, including revision comparisons and
pending-proposal limits. The host retains filesystem work and the collection and
target leases through settlement; worker transactions verify every held lease
before effects and commit. A failed reply is reconciled before discarding a
staged generation or restoring live files.
Collection history reads and experience-review outcomes use the same worker.
Doctor awaits legacy proposal imports before deleting their source sidecars.
Transaction-bound relocation kernels and read-only migration readers retain their
supplied connections. Proposal generations, schemas, limits, retention, and
rollback ordering are unchanged.
Skill Workshop change-feed reads and writes and skill usage reads and writes
execute in the existing shared-state worker. The retired proposal tables have no
runtime worker operations; Doctor reads them once in its one-shot export before
dropping them. See the
[proposal retirement](/reference/database-schemas/state-schema-history#skill-workshop-proposal-retirement-state-schema-20-same-version).
Cron receipt execution identity binding runs in the shared-state worker. Its
transaction rereads the exact live receipt owner and rechecks the caller's current

View file

@ -23,6 +23,11 @@ cannot parse its catalog. A known legacy index defect must not replace that
refusal with advice to repair the database using the older build. Doctor checks
compatibility before stopping the Gateway service.
Target-release checks distinguish the target's schema support from the running
inspector's capabilities. When shared state is too new for the target but still
readable by the inspector, preflight continues checking agent stores and reports
all incompatibilities without modifying the source files.
Changes may stay at the same schema version only when downgraded readers remain safe. New tables qualify because older builds ignore them. An explicitly compatible column on an existing table qualifies only when its declaration is exactly one bare nullable SQLite `STRICT` datatype: `ANY`, `BLOB`, `INT`, `INTEGER`, `REAL`, or `TEXT`. The declaration cannot have a default, `NOT NULL`, a primary or unique key, a check, a reference, a collation, a generated expression, or another suffix. Constrained existing-table additions require a schema-version bump or a companion table instead.
Linux Node worker cleanup uses the additive `node_worker_launch_process_scopes`
@ -456,8 +461,8 @@ The runner records the applied content version in the existing
deferred, new code uses that content version, and both `PRAGMA user_version` and
`schema_meta.schema_version` retain the previous published version. Content and
its marker commit together. Reopening skips migration steps already covered by
the marker, including the schema-16 Skill Workshop rebuild; it does not infer
completion from table shape or repeat the rebuild. This requires no new table,
the marker; it does not infer completion from table shape or repeat a
completed rebuild. This requires no new table,
configuration option, or environment override.
Current content is ready for readers even while its version is unpublished.

View file

@ -8,7 +8,7 @@ title: "Database worker migration inventory"
<!-- Generated by scripts/database-worker-inventory.mjs. Edit its classification evidence, then regenerate. -->
This snapshot contains **633 non-test files and 2531 call expressions** for the five primitives below. The campaign previously reported 404 files; that is a historical estimate, not a fixed target or a count of call expressions. This inventory follows current source and excludes import-only matches, comments, tests, fixtures, and test support. Its scan scope and exclusions are explicit below.
This snapshot contains **624 non-test files and 2496 call expressions** for the five primitives below. The campaign previously reported 404 files; that is a historical estimate, not a fixed target or a count of call expressions. This inventory follows current source and excludes import-only matches, comments, tests, fixtures, and test support. Its scan scope and exclusions are explicit below.
Regenerate with `pnpm db:worker-inventory:gen`; verify with `pnpm db:worker-inventory:check`. `node scripts/database-worker-inventory.mjs --json` emits every call's primitive, line, column, lexical operation path, optional variable-initializer binding, file owner, tier, and classification evidence. The script uses the repository's TypeScript parser and `rg`; it does not load application code or open a database.
@ -32,10 +32,10 @@ The scan covers JavaScript/TypeScript files under `src/`, `extensions/`, `packag
| Tier | Files | Call expressions |
| ---- | ----: | ---------------: |
| T1 | 261 | 797 |
| T2 | 90 | 372 |
| T3 | 75 | 269 |
| W | 281 | 1093 |
| T1 | 263 | 802 |
| T2 | 88 | 370 |
| T3 | 71 | 255 |
| W | 274 | 1069 |
## Profile priority and current cutover status
@ -88,7 +88,7 @@ Counts use `Q/F/S/A/R` in that order. Source locations are available in `--json`
| **src/config/sessions** · `src/config/sessions/session-transcript-search.ts` | 1/1/0/0/2 | 86 | Async durable search uses worker; process-held incognito remains native |
| **src/config/sessions** · `src/config/sessions/session-sharing-store.kernel.ts` | 1/1/0/0/0 | 38 | Member-row kernel shared by session readers |
| **src/config/sessions** · `src/config/sessions/session-sharing-store.ts` | 0/0/0/0/1 | 28 | Session-list member reads and membership mutations |
| **src/acp** · `src/acp/event-ledger.ts` | 1/1/1/0/0 | 265 | Runtime/mixed candidate; main-thread reachability needs tracing |
| **src/acp** · `src/acp/event-ledger.ts` | 1/1/1/0/0 | 264 | Runtime/mixed candidate; main-thread reachability needs tracing |
| **src/acp** · `src/acp/runtime/session-meta-keys.ts` | 2/1/0/0/0 | 51 | Runtime/mixed candidate; main-thread reachability needs tracing |
| **src/acp** · `src/acp/runtime/session-meta-write.kernel.ts` | 1/0/0/0/0 | 79 | Runtime/mixed candidate; main-thread reachability needs tracing |
| **src/acp** · `src/acp/runtime/session-meta-write.native.ts` | 0/0/2/0/0 | 96 | Runtime/mixed candidate; main-thread reachability needs tracing |
@ -97,7 +97,7 @@ Counts use `Q/F/S/A/R` in that order. Source locations are available in `--json`
| **src/agents** · `src/agents/agent-lifecycle-registry.ts` | 0/0/3/0/0 | 250 | Runtime/mixed candidate; main-thread reachability needs tracing |
| **src/agents** · `src/agents/plugin-model-catalog.kernel.ts` | 1/0/0/0/1 | 29 | Runtime/mixed candidate; main-thread reachability needs tracing |
| **src/agents** · `src/agents/workspace-state-store.kernel.ts` | 8/0/0/0/0 | 114 | Runtime/mixed candidate; main-thread reachability needs tracing |
| **src/agents** · `src/agents/workspace-state-store.ts` | 1/1/1/0/0 | 316 | Runtime/mixed candidate; main-thread reachability needs tracing |
| **src/agents** · `src/agents/workspace-state-store.ts` | 1/1/1/0/0 | 290 | Runtime/mixed candidate; main-thread reachability needs tracing |
| **src/agents/auth-profiles** · `src/agents/auth-profiles/candidate-stores.ts` | 0/0/0/1/0 | 147 | Runtime/mixed candidate; main-thread reachability needs tracing |
| **src/agents/auth-profiles** · `src/agents/auth-profiles/shared-store-bootstrap.ts` | 0/3/0/0/0 | 108 | Runtime/mixed candidate; main-thread reachability needs tracing |
| **src/agents/auth-profiles** · `src/agents/auth-profiles/sqlite-json.ts` | 5/2/0/0/0 | 40 | Runtime/mixed candidate; main-thread reachability needs tracing |
@ -171,11 +171,11 @@ Counts use `Q/F/S/A/R` in that order. Source locations are available in `--json`
| **src/config/sessions** · `src/config/sessions/session-accessor.sqlite-pending-inputs-repair.ts` | 5/0/0/0/0 | 35 | Runtime/mixed candidate; main-thread reachability needs tracing |
| **src/config/sessions** · `src/config/sessions/session-accessor.sqlite-pending-inputs.ts` | 6/2/0/0/0 | 365 | Runtime/mixed candidate; main-thread reachability needs tracing |
| **src/config/sessions** · `src/config/sessions/session-accessor.sqlite-projection-read.ts` | 1/0/0/0/0 | 120 | Runtime/mixed candidate; main-thread reachability needs tracing |
| **src/config/sessions** · `src/config/sessions/session-accessor.sqlite-projection.ts` | 0/0/0/2/0 | 372 | Runtime/mixed candidate; main-thread reachability needs tracing |
| **src/config/sessions** · `src/config/sessions/session-accessor.sqlite-projection.ts` | 0/0/0/2/0 | 373 | Runtime/mixed candidate; main-thread reachability needs tracing |
| **src/config/sessions** · `src/config/sessions/session-accessor.sqlite-provenance.ts` | 0/2/0/0/0 | 39 | Runtime/mixed candidate; main-thread reachability needs tracing |
| **src/config/sessions** · `src/config/sessions/session-accessor.sqlite-raw-delta-read.ts` | 2/0/0/0/0 | 194 | Runtime/mixed candidate; main-thread reachability needs tracing |
| **src/config/sessions** · `src/config/sessions/session-accessor.sqlite-read.ts` | 3/4/0/0/4 | 152 | Runtime/mixed candidate; main-thread reachability needs tracing |
| **src/config/sessions** · `src/config/sessions/session-accessor.sqlite-reclamation.ts` | 4/0/0/3/0 | 97 | Runtime/mixed candidate; main-thread reachability needs tracing |
| **src/config/sessions** · `src/config/sessions/session-accessor.sqlite-reclamation.ts` | 4/0/0/3/0 | 94 | Runtime/mixed candidate; main-thread reachability needs tracing |
| **src/config/sessions** · `src/config/sessions/session-accessor.sqlite-references.ts` | 2/0/0/0/0 | 104 | Runtime/mixed candidate; main-thread reachability needs tracing |
| **src/config/sessions** · `src/config/sessions/session-accessor.sqlite-replacement-projection.ts` | 0/0/0/1/0 | 300 | Runtime/mixed candidate; main-thread reachability needs tracing |
| **src/config/sessions** · `src/config/sessions/session-accessor.sqlite-replacement-read.ts` | 1/0/0/0/0 | 31 | Runtime/mixed candidate; main-thread reachability needs tracing |
@ -310,6 +310,8 @@ Counts use `Q/F/S/A/R` in that order. Source locations are available in `--json`
| **src/sessions** · `src/sessions/session-upstream-links.kernel.ts` | 3/0/0/0/0 | 94 | Runtime/mixed candidate; main-thread reachability needs tracing |
| **src/sessions** · `src/sessions/session-upstream-links.ts` | 1/0/2/0/0 | 48 | Runtime/mixed candidate; main-thread reachability needs tracing |
| **src/skills/library** · `src/skills/library/selection-read.kernel.ts` | 1/0/0/0/0 | 35 | Runtime/mixed candidate; main-thread reachability needs tracing |
| **src/skills/workshop** · `src/skills/workshop/changes.kernel.ts` | 3/0/0/0/0 | 58 | Runtime/mixed candidate; main-thread reachability needs tracing |
| **src/skills/workshop** · `src/skills/workshop/skill-usage.kernel.ts` | 2/0/0/0/0 | 26 | Runtime/mixed candidate; main-thread reachability needs tracing |
| **src/state** · `src/state/agent-deletion-journal-history.ts` | 0/2/0/0/0 | 20 | Runtime/mixed candidate; main-thread reachability needs tracing |
| **src/state** · `src/state/agent-deletion-journal-recovery.ts` | 1/0/0/0/0 | 118 | Runtime/mixed candidate; main-thread reachability needs tracing |
| **src/state** · `src/state/agent-deletion-journal.read.ts` | 1/0/0/0/0 | 121 | Runtime/mixed candidate; main-thread reachability needs tracing |
@ -324,9 +326,9 @@ Counts use `Q/F/S/A/R` in that order. Source locations are available in `--json`
| **src/state** · `src/state/openclaw-agent-canonical-validation-receipt.ts` | 0/1/0/0/0 | 53 | Runtime/mixed candidate; main-thread reachability needs tracing |
| **src/state** · `src/state/openclaw-agent-db-metadata-write.ts` | 1/0/0/0/0 | 20 | Runtime/mixed candidate; main-thread reachability needs tracing |
| **src/state** · `src/state/openclaw-agent-db-registry.read.ts` | 1/1/0/0/0 | 30 | Runtime/mixed candidate; main-thread reachability needs tracing |
| **src/state** · `src/state/openclaw-agent-db-registry.ts` | 3/0/2/0/0 | 52 | Runtime/mixed candidate; main-thread reachability needs tracing |
| **src/state** · `src/state/openclaw-agent-db-registry.ts` | 3/0/2/0/0 | 54 | Runtime/mixed candidate; main-thread reachability needs tracing |
| **src/state** · `src/state/openclaw-quarantine-store.ts` | 5/2/0/0/0 | 100 | Runtime/mixed candidate; main-thread reachability needs tracing |
| **src/state** · `src/state/openclaw-state-db-maintenance.ts` | 1/0/0/0/0 | 599 | Runtime/mixed candidate; main-thread reachability needs tracing |
| **src/state** · `src/state/openclaw-state-db-maintenance.ts` | 1/0/0/0/0 | 457 | Runtime/mixed candidate; main-thread reachability needs tracing |
| **src/state** · `src/state/openclaw-state-db.ts` | 0/0/1/0/0 | 397 | Runtime/mixed candidate; main-thread reachability needs tracing |
| **src/state** · `src/state/user-github-connections.ts` | 1/0/3/0/0 | 188 | Runtime/mixed candidate; main-thread reachability needs tracing |
| **src/state** · `src/state/user-model-accounts.ts` | 2/1/3/0/0 | 98 | Runtime/mixed candidate; main-thread reachability needs tracing |
@ -348,12 +350,11 @@ Counts use `Q/F/S/A/R` in that order. Source locations are available in `--json`
| **extensions/codex** · `extensions/codex/src/migration/native-task-assignments.ts` | 6/0/0/0/0 | 132 | Schema/startup/migration candidate; verify no runtime caller |
| **src/acp** · `src/acp/runtime/session-meta-migration-embedded.ts` | 0/1/1/0/1 | 102 | Schema/startup/migration candidate; verify no runtime caller |
| **src/acp** · `src/acp/runtime/session-meta-migration-keys.ts` | 1/0/0/0/0 | 112 | Schema/startup/migration candidate; verify no runtime caller |
| **src/acp** · `src/acp/runtime/session-meta-startup.ts` | 0/0/0/0/1 | 152 | Schema/startup/migration candidate; verify no runtime caller |
| **src/acp** · `src/acp/runtime/session-meta-startup.ts` | 0/0/0/0/1 | 137 | Schema/startup/migration candidate; verify no runtime caller |
| **src/agents** · `src/agents/plugin-model-catalog.kernel.ts` | 4/0/0/0/0 | 68 | Native replacement is Doctor import only: src/commands/doctor-plugin-model-catalog.ts:103 -> plugin-model-catalog.ts:495,517 -> :162. The other caller is plugin-model-catalog.worker.ts:94; readPluginModelCatalogEntries remains T1. |
| **src/agents** · `src/agents/plugin-model-catalog.ts` | 3/0/0/3/0 | 111 | Only doctor-plugin-model-catalog.ts:103,126 reaches repair/import/receipt retirement; runtime replacement dispatches at plugin-model-catalog.ts:584; ModelRegistry synchronous kernel reads stay T1 |
| **src/agents** · `src/agents/workspace-alias-rebind.ts` | 5/4/1/0/0 | 86 | Only src/commands/doctor-workspace-alias.ts:67,107,131 invokes detect/rebind; readWorkspaceMoveState is private at :166,282. Doctor health/lint reaches detection via src/flows/doctor-health-contributions-final.ts:269 and doctor-health-contribution-runners.workspace.ts:77; repair is installed by src/commands/doctor-config-flow.ts:83,93. |
| **src/agents** · `src/agents/workspace-state-store.kernel.ts` | 2/2/0/0/0 | 172 | Worker runtime/read dispatch plus Doctor workspace-alias-rebind.ts:83,324, migration workspace-setup-store.ts:528 and relocation retirement workspace-state-store.ts:256; native identity/deletion stay T1 |
| **src/agents** · `src/agents/workspace-state-store.ts` | 1/0/0/0/0 | 264 | Only commands/doctor-skill-workshop-workspaces.ts:266 retires migration attestations |
| **src/agents/worktrees** · `src/agents/worktrees/registry.ts` | 2/0/1/0/0 | 73 | Only migration discovery src/infra/state-migrations.doctor-discovery.ts:84 and Doctor repair src/config/sessions/worktree-workspace-migration.ts:116 call the list; the latter requires doctor-fix mode. The private runRegistryMigration transaction is called only by discardLegacyRegistryWorktrees and rewriteRegistryWorktreePathsForMigration; their sole production caller is the managed-worktrees step at src/infra/state-migrations.doctor.ts:1256,1258 (Doctor/startup). It acquires the existing schema-maintenance owner before opening the transaction database. |
| **src/channels** · `src/channels/message/ingress-queue.migration.ts` | 1/1/2/0/0 | 116 | Schema/startup/migration candidate; verify no runtime caller |
| **src/config/sessions** · `src/config/sessions/legacy-main-session-migration-claims.ts` | 1/0/0/0/2 | 128 | Schema/startup/migration candidate; verify no runtime caller |
@ -419,7 +420,6 @@ Counts use `Q/F/S/A/R` in that order. Source locations are available in `--json`
| **src/plugin-state** · `src/plugin-state/plugin-state-store.kernel.ts` | 1/0/0/0/0 | 260 | Native raw-row inspection only: src/infra/state-migrations.plugin-doctor-context.ts:416 -> plugin-state-store.sqlite.ts:394. Other callers are plugin-state.worker.ts:104 -> plugin-state-store.reads.ts:111 and plugin-state.worker.ts:173 -> plugin-state-store.journal.ts:122; public key-range facade is asynchronous at plugin-state-store.ts:580. |
| **src/plugin-state** · `src/plugin-state/plugin-state-store.retention.ts` | 0/1/0/0/0 | 94 | Only migration/Doctor batch creates retention: src/infra/state-migrations.plugin-doctor-context.ts:409 -> plugin-state-store.ts:597 -> plugin-state-store.sqlite.ts:133,139. The conditional retention refresh at plugin-state-store.retention.ts:228 shares that batch; native ordinary registration (:107) and worker registration (plugin-state.worker.ts:190) omit retention. |
| **src/plugin-state** · `src/plugin-state/plugin-state-store.sqlite.ts` | 1/0/0/0/0 | 356 | Only src/infra/state-migrations.plugin-doctor-context.ts:466 calls exact-row deletion, after repair-authority validation at :464. Context creation is limited to state-migrations.plugin-doctor.ts:200,325; no runtime keyed-store method exposes this repair operation. |
| **src/skills/workshop** · `src/skills/workshop/store-sqlite-schema.ts` | 0/0/1/0/0 | 70 | Schema/startup/migration candidate; verify no runtime caller |
| **src/state** · `src/state/agent-deletion-journal.ts` | 0/0/1/0/0 | 153 | Pre-open registration/schema/lease admission at openclaw-agent-db-registry.ts:79, schema.ts:519 and lease.ts:161,495,699; runtime journal mutation/fence reads stay T1 |
| **src/state** · `src/state/config-machine-state-write.ts` | 1/1/1/0/0 | 155 | Only migrations src/infra/state-migrations.config-machine-state.ts:71 and state-migrations.update-check.ts:66; Doctor roots doctor-config-preflight.cron.ts:27 and state-migrations.doctor.ts:910,1394. |
| **src/state** · `src/state/openclaw-agent-db-lease.ts` | 9/3/7/0/0 | 173 | Boot or lock/lease primitive; exception is operation-scoped |
@ -467,12 +467,9 @@ Counts use `Q/F/S/A/R` in that order. Source locations are available in `--json`
| **src/commands** · `src/commands/doctor-session-sqlite-verification.ts` | 0/1/0/0/1 | 139 | CLI/Doctor/developer one-shot; reclassify if called by Gateway |
| **src/commands** · `src/commands/doctor-session-transcript-headers.ts` | 0/2/0/1/0 | 122 | CLI/Doctor/developer one-shot; reclassify if called by Gateway |
| **src/commands** · `src/commands/doctor-session-transcript-labels.ts` | 0/0/0/1/0 | 229 | CLI/Doctor/developer one-shot; reclassify if called by Gateway |
| **src/commands** · `src/commands/doctor-skill-workshop-read.kernel.ts` | 1/0/0/0/0 | 23 | CLI/Doctor/developer one-shot; reclassify if called by Gateway |
| **src/commands** · `src/commands/doctor-skill-workshop-sidecars.ts` | 0/1/0/0/0 | 320 | CLI/Doctor/developer one-shot; reclassify if called by Gateway |
| **src/commands** · `src/commands/doctor-skill-workshop-sqlite.ts` | 2/1/1/0/0 | 147 | CLI/Doctor/developer one-shot; reclassify if called by Gateway |
| **src/commands** · `src/commands/doctor-skill-workshop-workspaces.ts` | 2/3/2/0/0 | 123 | CLI/Doctor/developer one-shot; reclassify if called by Gateway |
| **src/commands** · `src/commands/doctor-skill-workshop-retire-proposals.ts` | 0/0/1/0/0 | 570 | CLI/Doctor/developer one-shot; reclassify if called by Gateway |
| **src/commands** · `src/commands/doctor-telegram-general-topic-conversations.ts` | 12/0/0/1/1 | 64 | CLI/Doctor/developer one-shot; reclassify if called by Gateway |
| **src/commands** · `src/commands/doctor-update-rehearsal-inventory.ts` | 0/1/0/0/0 | 330 | CLI/Doctor/developer one-shot; reclassify if called by Gateway |
| **src/commands** · `src/commands/doctor-update-rehearsal-inventory.ts` | 0/1/0/0/0 | 328 | CLI/Doctor/developer one-shot; reclassify if called by Gateway |
| **src/commands** · `src/commands/doctor-update-run.ts` | 1/1/0/0/0 | 65 | CLI/Doctor/developer one-shot; reclassify if called by Gateway |
| **src/commands** · `src/commands/doctor-update-schema-guard.ts` | 1/1/0/0/0 | 70 | CLI/Doctor/developer one-shot; reclassify if called by Gateway |
| **src/commands** · `src/commands/doctor/auth-alias-receipt.ts` | 0/0/2/0/0 | 151 | CLI/Doctor/developer one-shot; reclassify if called by Gateway |
@ -504,9 +501,8 @@ Counts use `Q/F/S/A/R` in that order. Source locations are available in `--json`
| **src/infra** · `src/infra/update-managed-service-handoff-database-recovery.ts` | 2/0/0/0/0 | 137 | Explicit update recover CLI -> recoverImmutableUpdate -> withImmutableUpdateOwner({ recover: true }) only; ordinary lease and Gateway readers never call this cold-journal admission |
| **src/infra** · `src/infra/update-run-ledger.ts` | 1/3/0/0/0 | 137 | active initializer requires stale-run opt-in only from src/cli/update-cli/update-command-run.ts:304,310,313; guard src/infra/update-run-ledger.ts:127 excludes Gateway/campaign callers. Repairs: update-repair-command.ts:141; interruption: update-command-mutable-signals.ts:158. |
| **src/secrets** · `src/secrets/store/secret-store.ts` | 1/0/1/0/0 | 364 | Only cli/secrets-store-cli.ts:251 mutates allowed hosts; runtime reads and other writes remain T1 |
| **src/skills/workshop** · `src/skills/workshop/store-sqlite-record.ts` | 1/1/0/0/0 | 44 | Doctor native read/update src/commands/doctor-skill-workshop-sqlite.ts:333,349; other callers use src/skills/workshop/store.worker.ts:68,129,149,167,182,188. |
| **src/state** · `src/state/openclaw-agent-db-path-repair.ts` | 3/0/0/0/0 | 31 | src/commands/doctor-agent-database-paths.ts:24 under Doctor repair; registered health runner src/flows/doctor-health-contribution-runners.state.ts:122. |
| **src/state** · `src/state/openclaw-agent-db-registry.ts` | 1/0/1/0/0 | 179 | Plural unregister: CLI claw removal src/claws/lifecycle-delete-support.ts:519 → cli/claws-cli.runtime.ts:562; CLI agent deletion src/commands/agents.commands.delete.ts:450 → cli/program/register.agent.ts:183. |
| **src/state** · `src/state/openclaw-agent-db-registry.ts` | 1/0/1/0/0 | 190 | Plural unregister: CLI claw removal src/claws/lifecycle-delete-support.ts:519 → cli/claws-cli.runtime.ts:562; CLI agent deletion src/commands/agents.commands.delete.ts:450 → cli/program/register.agent.ts:183. |
| **src/state** · `src/state/openclaw-state-ownership-operations.ts` | 1/0/1/0/0 | 76 | Only src/cli/program/register.database.ts:56 invokes claim; its private claimOwnershipRow calls remain within this CLI operation. |
| **src/state** · `src/state/openclaw-state-snapshot-sanitizer.ts` | 2/0/0/0/0 | 49 | CLI SQLite/git backups: src/snapshot/local-repository.ts:148, git-backup.ts:310 → openclaw-snapshot-copy.ts:68; archive/Doctor backup via src/infra/backup-create.ts:320,332,352 → backup-sqlite-snapshot.ts:287; developer reliability scripts also use the provider. |
| **src/state** · `src/state/user-channel-identities.ts` | 6/3/2/0/0 | 120 | Gateway uses user-channel-identities.worker.ts and state-read; native resolver serves channel-operator-authority.ts CLI/updater capture via update-requester-authority.ts |
@ -621,7 +617,7 @@ Counts use `Q/F/S/A/R` in that order. Source locations are available in `--json`
| **src/config/sessions** · `src/config/sessions/session-pending-input-history-read.worker.ts` | 0/0/0/0/1 | 12 | Worker implementation; keep SQL in this owner |
| **src/config/sessions** · `src/config/sessions/session-pending-input-withdrawal.worker.ts` | 2/1/0/1/0 | 33 | Worker implementation; keep SQL in this owner |
| **src/config/sessions** · `src/config/sessions/session-transcript-hydration-read.worker.ts` | 0/0/0/0/1 | 17 | Worker implementation; keep SQL in this owner |
| **src/config/sessions** · `src/config/sessions/session-transcript-index-status.worker.ts` | 7/5/0/0/0 | 157 | Worker implementation; keep SQL in this owner |
| **src/config/sessions** · `src/config/sessions/session-transcript-index-status.worker.ts` | 7/5/0/0/0 | 116 | Worker implementation; keep SQL in this owner |
| **src/config/sessions** · `src/config/sessions/session-transcript-projection-publication.worker.ts` | 0/1/0/0/0 | 98 | Worker implementation; keep SQL in this owner |
| **src/config/sessions** · `src/config/sessions/session-transcript.worker.ts` | 0/0/0/0/13 | 105 | Worker implementation; keep SQL in this owner |
| **src/cron** · `src/cron/store/dispatch.worker.ts` | 0/0/4/0/0 | 138 | Worker implementation; keep SQL in this owner |
@ -666,7 +662,7 @@ Counts use `Q/F/S/A/R` in that order. Source locations are available in `--json`
| **src/gateway/worker-environments** · `src/gateway/worker-environments/placement-row-codec.ts` | 5/0/0/0/0 | 157 | Snapshots use openclaw-state-read.worker.ts; placement-lifecycle.worker.ts serves point lookups on the existing placement actor. Native reconciliation guards remain T1.; placement-dispatch-store.worker.ts:38 and placement-turn-claims.ts:112 claim path; claims only invoked at placement-turn-claims.worker.ts:160,175,345. Native placement-store.ts:75,76 selects clear/wait/validate methods that do not claim.; Transitions run only in placement-transitions.worker.ts and prepared binding in placement-lifecycle.worker.ts. |
| **src/gateway/worker-environments** · `src/gateway/worker-environments/placement-session-tool-operations.kernel.ts` | 12/0/0/0/0 | 36 | Factory runs in placement-session-tool-operations.worker.ts; claim, reconcile and terminal-failure cleanup now only run through placement-turn-claims.worker.ts |
| **src/gateway/worker-environments** · `src/gateway/worker-environments/placement-session-tool-operations.worker.ts` | 0/0/1/0/0 | 24 | Worker implementation; keep SQL in this owner |
| **src/gateway/worker-environments** · `src/gateway/worker-environments/placement-transitions.worker.ts` | 3/0/0/0/0 | 147 | Worker implementation; keep SQL in this owner |
| **src/gateway/worker-environments** · `src/gateway/worker-environments/placement-transitions.worker.ts` | 3/0/0/0/0 | 168 | Worker implementation; keep SQL in this owner |
| **src/gateway/worker-environments** · `src/gateway/worker-environments/placement-turn-claims.ts` | 7/0/0/0/0 | 77 | placement-store.ts:102 selects only native restart/wait/validation; claim/release/cancel mutations run in placement-turn-claims.worker.ts:102,117,191,200,287,355,360; placement-store.ts:132 supplies worker mutations; placement-turn-claims.worker.ts:299,355,360 executes release/manifest methods |
| **src/gateway/worker-environments** · `src/gateway/worker-environments/placement-turn-claims.worker.ts` | 1/0/1/0/0 | 66 | Worker implementation; keep SQL in this owner |
| **src/gateway/worker-environments** · `src/gateway/worker-environments/placement-workspace-journal.ts` | 8/0/0/0/0 | 62 | Read dispatch at state-read.worker.ts:653 and journal mutation factory at placement-workspace-journal.worker.ts:30; Acceptance at placement-turn-claims.worker.ts:190, journal abort via placement-workspace-journal.worker.ts:30 and manifest drain via placement-transitions.worker.ts:82; native move drain supplies no manifest and skips cleanup |
@ -758,14 +754,7 @@ Counts use `Q/F/S/A/R` in that order. Source locations are available in `--json`
| **src/skills/lifecycle** · `src/skills/lifecycle/upload-store-commit.ts` | 2/1/1/0/0 | 100 | Worker implementation; keep SQL in this owner |
| **src/skills/lifecycle** · `src/skills/lifecycle/upload-store.kernel.ts` | 7/4/5/0/0 | 67 | Worker implementation; keep SQL in this owner |
| **src/skills/lifecycle** · `src/skills/lifecycle/upload-store.sqlite.ts` | 4/5/3/0/0 | 64 | Worker implementation; keep SQL in this owner |
| **src/skills/workshop** · `src/skills/workshop/collection-review.kernel.ts` | 2/0/0/0/0 | 130 | Worker implementation; keep SQL in this owner |
| **src/skills/workshop** · `src/skills/workshop/curator.kernel.ts` | 3/0/0/0/0 | 23 | Worker implementation; keep SQL in this owner |
| **src/skills/workshop** · `src/skills/workshop/store-proposal.kernel.ts` | 3/4/0/0/0 | 52 | Worker implementation; keep SQL in this owner |
| **src/skills/workshop** · `src/skills/workshop/store-sqlite-event.ts` | 2/2/0/0/0 | 49 | Worker implementation; keep SQL in this owner |
| **src/skills/workshop** · `src/skills/workshop/store-sqlite-record.ts` | 1/0/0/0/0 | 86 | Only src/skills/workshop/store-proposal.kernel.ts:73,168 inserts; sole executors store.worker.ts:139,157. |
| **src/skills/workshop** · `src/skills/workshop/store-sqlite-rollback.ts` | 3/3/0/0/0 | 39 | Worker implementation; keep SQL in this owner |
| **src/skills/workshop** · `src/skills/workshop/store-sqlite-transition.ts` | 1/0/0/0/0 | 45 | Worker implementation; keep SQL in this owner |
| **src/skills/workshop** · `src/skills/workshop/store.worker.ts` | 0/0/2/0/0 | 64 | Worker implementation; keep SQL in this owner |
| **src/skills/workshop** · `src/skills/workshop/changes.worker.ts` | 0/0/2/0/0 | 19 | Worker implementation; keep SQL in this owner |
| **src/state** · `src/state/agent-deletion-journal-authority.worker.ts` | 0/1/0/0/0 | 12 | Worker implementation; keep SQL in this owner |
| **src/state** · `src/state/agent-deletion-journal.read.ts` | 0/1/0/0/0 | 72 | src/state/openclaw-state-read-registry.ts:65 under read worker :679; other executor src/state/openclaw-agent-execution.worker.ts:190. |
| **src/state** · `src/state/agent-deletion-journal.snapshot.worker.ts` | 1/0/0/0/0 | 25 | Worker implementation; keep SQL in this owner |

View file

@ -6,7 +6,7 @@ summary: "Build, test, and publish custom SKILL.md workspace skills or personal
read_when:
- You are creating a new custom skill
- You need a quick starter workflow for SKILL.md-based skills
- You want to use Skill Workshop to propose a skill for agent review
- You want the agent to save or update a skill for you with Skill Workshop
---
Skills teach the agent how and when to use tools. Each skill is a directory
@ -98,9 +98,8 @@ You do not need host shell access or permission to edit shared Gateway settings.
You can also ask the agent to create or improve a personal skill. Its
`skill_workshop` tool uses the Gateway's authorized library service; it does not
write managed revision directories directly. The result distinguishes a
published skill from a pending proposal and explains when the session can use
it. Ask explicitly to use the new revision in the current session or share it
write managed revision directories directly. The result names the published
revision and explains when the session can use it. Ask explicitly to use the new revision in the current session or share it
with the team.
The single-admin default remains the workspace workflow above. Extra channel
@ -189,46 +188,21 @@ metadata: { "openclaw": { "requires": { "bins": ["gemini"] }, "primaryEnv": "GEM
</Accordion>
</AccordionGroup>
## Propose via Skill Workshop
## Learned skills (Skill Workshop)
For agent-drafted skills or when you want operator review before a skill goes
live, use [Skill Workshop](/tools/skill-workshop) proposals instead of writing
`SKILL.md` directly.
Agents also write their own skills through [Skill Workshop](/tools/skill-workshop).
Those learned skills apply immediately and keep their previous versions, so any
change can be undone. Inspect and manage them from the CLI:
```bash
# Propose a brand-new skill
openclaw skills workshop propose-create \
--name "hello-world" \
--description "A simple skill that prints a greeting." \
--proposal ./PROPOSAL.md
# Propose an update to an existing skill
openclaw skills workshop propose-update hello-world \
--proposal ./PROPOSAL.md \
--description "Updated greeting skill"
openclaw skills workshop list
openclaw skills workshop changes
openclaw skills workshop show hello-world
openclaw skills workshop archive hello-world --reason "Replaced by greeting"
openclaw skills workshop restore hello-world
```
Use `--proposal-dir` when the proposal includes support files:
```bash
openclaw skills workshop propose-create \
--name "hello-world" \
--description "A simple skill that prints a greeting." \
--proposal-dir ./hello-world-proposal/
```
The directory must contain `PROPOSAL.md` at its root. Support files go under
`assets/`, `examples/`, `references/`, `scripts/`, or `templates/`.
After review:
```bash
openclaw skills workshop inspect <proposal-id>
openclaw skills workshop evaluate <proposal-id>
openclaw skills workshop apply <proposal-id>
```
See [Skill Workshop](/tools/skill-workshop) for the full proposal lifecycle.
See [`openclaw skills workshop`](/cli/skills#skill-workshop) for every option.
## Publishing to ClawHub
@ -280,7 +254,7 @@ personal owner or an organization where you have publisher access.
Loading order, gating, allowlists, and SKILL.md format.
</Card>
<Card title="Skill Workshop" href="/tools/skill-workshop" icon="flask">
Proposal queue for agent-drafted skills.
Skills your agent saves and updates on its own, with undo.
</Card>
<Card title="Skills config" href="/tools/skills-config" icon="gear">
Full `skills.*` config schema.

View file

@ -1,350 +1,169 @@
---
summary: "Turn corrections and successful work into reusable skills through Skill Workshop"
summary: "How OpenClaw reviews finished work in the background and saves lessons as learned skills"
read_when:
- You want OpenClaw to learn reusable procedures from completed conversations
- You are choosing between off, propose, and auto self-learning modes
- You need to understand self-learning safety, cost, privacy, or troubleshooting
- You want OpenClaw to learn reusable procedures from its conversations
- You are deciding whether to leave self-learning on or turn it off
- You need to know what a background review sees, costs, or changes
- Nothing is being learned and you want to know why
title: "Self-learning"
sidebarTitle: "Self-learning"
---
Self-learning turns corrections and successful work into reusable skills. Skills
are the durable unit: they hold procedures that future sessions can discover and
follow. [Skill Workshop](/tools/skill-workshop) owns the agent's learned skills.
Self-learning turns corrections and hard-won procedures into
[learned skills](/tools/skill-workshop) that future sessions load. It is on by
default (`skills.workshop.autonomous.mode: "auto"`).
The default mode is `auto`. Background learning uses normal agent file tools to
maintain the Workshop directory, like weekly collection review. Choose `propose`
to stage drafts for review instead, or `off` to disable autonomous learning.
Learning happens in three places:
## Immediate repair
- **The foreground agent** patches a learned skill that misled it and saves a
procedure after hard multi-step work, in the same turn.
- **A background review** looks back over a conversation after enough work and
saves what the foreground agent did not. This page covers it.
- **Unused-skill cleanup** archives learned skills unused for 30 days. See
[Unused-skill cleanup](/tools/skill-workshop#unused-skill-cleanup).
When the foreground agent discovers that a skill it used is wrong or incomplete,
it reads the current live skill and drafts a targeted patch through Skill
Workshop in the same turn. If the complete skill does not fit the selected
model's read budget, `prepare_patch` can authorize one non-empty unique exact
span and return bounded surrounding context. The next `patch` must quote that
same span, and the authorization expires after one attempt or any target change.
A second `prepare_patch` for that skill is rejected until the active authorization
is consumed or invalidated. A runtime usage receipt prevents foreground repair of
skills that the run did not use. Autonomous mode controls the outcome: `off`
disables the repair, `propose` leaves it pending for explicit review and apply,
and `auto` scans and applies it immediately. The repair still goes through
proposal storage, hash binding, the security scanner, and rollback capture.
Every change applies immediately, is announced in the conversation, and can be
undone. See [Undo](/tools/skill-workshop#undo).
Immediate repair changes the live skill for new sessions. It does not rewrite the
skill snapshot already loaded into the running session. The delayed experience
review remains a fallback for durable learning that the foreground agent did not
repair itself.
## When a review runs
## Experience review
OpenClaw counts model iterations per session, across turns. When a session
reaches 10 since its last review, a review is queued and the count starts over.
The count also resets when the foreground turn itself changed a learned skill,
so work the agent already saved is not reviewed again.
Every learning decision comes from a model reviewing real evidence, not a
template or pattern-matching path. The conversation and skill files are evidence,
not permission to resume tasks or execute the procedures under review.
A turn that read or viewed a learned skill also queues a review, whatever the
count, so a skill that just misled or helped the agent gets a fresh look. The
same eligibility rules apply, and a turn that changed a learned skill itself
still skips the review.
After substantial work, OpenClaw can run one detached background review to find
a reusable recovery technique or a stable procedure that would remove at least
two future model or tool round trips. Deep turns the user interrupted qualify
too: the wrong path and its correction are exactly the evidence worth keeping.
The reviewer is told when a turn was interrupted and captures only procedures
that visibly worked before the stop. Turns that ended in a provider or prompt
error never schedule a review. That failure is transient environment noise, and
a review on the same model would likely hit it again.
A queued review starts after 30 seconds with no agent or reply run active; later
activity in the same session restarts that wait. Reviews run one at a time.
The foreground reply never waits for a review.
Experience review starts only when all of these conditions hold:
These turns do not count:
- the foreground turn completed or was interrupted, but did not end in a
provider or prompt error.
- the current turn used at least 10 model iterations.
- the run was an eligible foreground conversation, not Incognito, cron,
heartbeat, memory, overflow, hook, subagent, or review work.
- the runtime reported the resolved provider, model, and actual availability of
`skill_workshop`.
- the system has been quiet for 30 seconds.
- no agent or reply run is still active.
- turns that ended in a provider or prompt error. Turns you interrupted still
count.
- cron, heartbeat, hook, subagent, memory, and Incognito sessions, and
background Workshop runs themselves.
- turns on a runtime that does not report its resolved model and whether
`skill_workshop` was available. The embedded runner and the Codex app-server
harness report both.
- turns where tool policy hides `skill_workshop`.
A later foreground completion in the same session restarts the quiet period.
It does not replace the saved evidence unless that turn also qualifies for review.
Pending reviews belong to an agent and session together, so agents using `global`
retain separate candidates. Experience reviews use one Workshop slot within the
[shared background work budget](/concepts/queue#background-work).
The foreground answer never waits for the model's review.
Compacted sessions are eligible. Counts are kept in memory, so a Gateway restart
starts them over.
OpenClaw records where the completed turn ends, then reads its full model context
asynchronously after the quiet period. The reviewer connects earlier requirements
and corrections with observed results across that retained conversation, even
when the latest turn is routine. Later messages are excluded. If the saved
turn was rewritten or removed, the review records a failure instead of using
different evidence. The review runs under a private detached session identity.
Its messages never enter the foreground transcript or session record. Reviews
retain the foreground session's sandbox policy.
## What a review does
In `auto` mode, the reviewer uses ordinary directory, file, patch, and shell
tools under the source session's permissions. It can inspect complete skills and
supporting files, correct several connected files, and verify the result. Its
file tools are rooted at the Workshop directory. Shell commands retain the
operator's existing approval policy. An enabled sandbox must provide writable
access to that directory. The run uses the normal configured agent timeout.
The review forks the conversation through the finished turn and appends one
instruction, reusing the foreground provider, model, auth profile, and tool
schemas so the provider's prompt cache applies. Model fallbacks are disabled. It
runs on the embedded OpenClaw harness under a private session that never
appears in your transcript.
The reviewer shares the weekly collection's maintenance instructions: audit
before editing, give a procedure one home, preserve distinct tasks, and verify
the resulting files. New learning should replace a misleading rule or strengthen
an existing one rather than append another copy. A covered lesson needs no edit.
The runtime receipt identifies skills actually used in the foreground turn.
Ordinary directory reads discover the current collection without a separate
clipped inventory.
The review can look things up with `read`, `ls`, `view_image`, `web_search`,
`web_fetch`, `sessions_history`, `sessions_search`, `memory_search`, and
`memory_get`; `skill_workshop` is the only tool that changes anything. Tools that
act, such as `exec`, `write`, or `message`, return a normal result such as
`exec is not available in this background run.` That result is not a failure,
and the review never re-runs your task. The review must `view` a skill
before it patches, writes, or archives it, and an archive needs `absorbed_into`
or `reason`.
In `propose` mode, only `skill_workshop` executes. The reviewer can inspect and
read before staging one create, patch, update, or revision. Existing proposal
read/hash/size validation remains in effect. The draft stays pending even if the
operator enables automatic learning during that review.
The reviewer looks for:
Each review gets one attempt. A failure is recorded instead of retried. Automatic
maintenance records `completed` when the agent run succeeds. That status is not
a claim that a file changed. Completed file edits survive later failure or
cancellation, and future sessions receive a refreshed skill snapshot. Source
session deletion, replacement, or a permission-mode change fences retained tools.
- a correction to your approach, output, or style.
- a non-obvious technique, fix, or command sequence that worked after trial and
error.
- a learned skill that was wrong, missing a step, or outdated.
Good candidates include:
It lists learned skills first, then prefers patching a skill that was used or
already covers the task, then adding a `references/`, `templates/`, or
`scripts/` file to one, and creates a new skill only when none covers the task.
Listed skills that cover the same class of task get merged into one umbrella
skill: the review patches the survivor and archives the rest with
`absorbed_into`. When the turn was interrupted, it keeps only steps that visibly
worked before the stop.
- a reliable recovery after repeated tool or model failures.
- a durable user correction or standing instruction ("from now on," "always,"
"never," "stop doing X"), embedded as a procedure step in the skill governing
that work.
- a non-obvious ordering constraint that prevented a recurring error.
- a stable multi-step workflow that required repeated discovery.
- a reusable preflight that would avoid several future calls.
It does not capture environment-specific or transient failures, negative claims
about tools, unresolved failures or guesses, one-off tasks, personal facts,
secrets, or generic advice without concrete commands, paths, or ids. When
nothing durable was learned, it changes nothing.
The reviewer should abstain for:
## What you see
- routine successful work or a one-time request.
- personal facts and simple preferences.
- transient environment or service failures.
- generic advice without concrete supporting evidence.
- unsupported negative claims.
- secrets and credential material.
If the review changed a skill, one line is posted to the conversation that
triggered it and mirrored into the session transcript:
## Mode policy
```text
💾 Learned: updated `deploy-staging` (tightened the rollback step). Say "undo" to revert this skill change.
```
| Mode | Capture behavior |
| --------- | ---------------------------------------------------------------------------- |
| `off` | Does not create experience-review captures. |
| `propose` | Creates or revises pending proposals. Nothing applies automatically. |
| `auto` | Maintains Workshop skills with normal agent file tools. This is the default. |
Channel-less Control UI sessions get the line as a transcript entry. Nothing is
posted when nothing changed. Reply "undo" and the agent restores the previous
version.
Set the mode with the CLI:
A review stops without changing anything further if you turn learning off, or
if the source session is deleted, replaced, or changes permission mode while it
runs. Changes it already made stay, each with a saved previous version.
## Turn it on or off
```bash
openclaw config set skills.workshop.autonomous.mode auto
openclaw config set skills.workshop.autonomous.mode propose
openclaw config set skills.workshop.autonomous.mode off
openclaw config set skills.workshop.autonomous.mode auto
```
Or edit `~/.openclaw/openclaw.json`:
```json5
{
skills: {
workshop: {
autonomous: {
mode: "auto",
},
},
},
}
```
Changing the mode does not alter existing proposals or applied skills. Manual
learning sessions, `/learn`, and explicit Workshop requests remain available in all
three modes.
## Why auto is safe to default
Automatic background learning follows the same normal file-edit semantics as
weekly collection maintenance:
- **Workshop ownership:** file tools stay in
`<state-dir>/agents/<agentId>/agent/workshop-skills`. Other skill roots remain
outside the maintenance task.
- **Existing permissions:** the run preserves the source session's permission
mode, tool restrictions, and shell approval policy. Conversation evidence does
not grant extra access.
- **Independent lifecycle:** foreground work does not await the review. Gateway
drain and source invalidation close the review's authority.
- **Editorial judgment:** the agent reads complete relevant files, preserves
useful meaning, and checks its changes rather than targeting a size or count.
Direct maintenance does not create proposals, run a post-turn scanner, or record
automatic rollback snapshots. Use backups for recovery from unwanted direct
edits. Explicit proposals and immediate foreground repair retain their existing
scanner, hash binding, size validation, and rollback metadata.
Reject a pending miscapture with one command:
```bash
openclaw skills workshop reject <proposal-id> --reason "Not reusable"
```
Proposal captures remain visible in `openclaw skills workshop list`. Direct
maintenance changes appear in the installed Workshop skills, not as proposal
records. Weekly review results remain in automation history. Retained legacy
backups keep their [restore path](/tools/skill-workshop#changes-and-recovery).
Residual risk remains: an agent can make an incorrect edit. Inspect installed
skills in Workshop, or choose `propose` when every capture needs human review.
## Runtime support
Delayed experience review requires the runtime to report its resolved model and
actual `skill_workshop` availability. The embedded runner and Codex app-server
harness report those facts. Codex also reports its exact model-iteration count.
Other CLI-backed runtimes fail closed until they provide the same runtime facts.
`/learn` does not depend on delayed review and continues to work on those
runtimes.
The Control UI **Plugins → Skill workshop** page has the same switch. `off`
stops background reviews and unused-skill cleanup. The agent can still create or
update learned skills when you ask, through `/learn`, or in a
**Learn from past conversations** session.
## Cost and privacy
Experience review adds one model run on the configured provider only after a
substantial turn, not after every message. The review can make several requests
while it inspects, edits, and verifies skills.
A review is one extra model run on the foreground provider and model after
substantial work, not after every message. It can make several requests while
it views and edits skills. Prompt-cache reuse lowers the cost of re-reading the
conversation; provider pricing still applies.
The review creates a detached view of the foreground model context and appends
one small user message. Storage-only native prompt payloads stay in the original
transcript, whose stored bytes the review does not change. It uses a private
detached session identity while preserving the
foreground provider, model, auth profile, bootstrap context, tool schemas, and
prompt-cache affinity. Removing unavailable skill guidance changes the prompt,
so only compatible prefixes can be reused. The review never becomes part of
the foreground session.
The reviewer reuses the foreground provider, model, and available auth identity,
with model fallbacks disabled. Provider pricing and data-handling terms apply to
the additional run.
Weekly [collection review](/tools/skill-workshop#collection-review) uses the
agent's configured model and normal cron scheduling. Skill bodies remain review
material, not active instructions. Completed edits persist. There is no
collection-wide transaction or automatic rollback.
**Learn from past conversations** in Workshop opens a normal agent session with
the learning instructions. The agent uses its configured model, permitted tools,
existing skills, and accessible conversation history. It chooses what to read.
There is no separate scan threshold, transcript bundle, or batch cursor.
The session follows the current Workshop mode: `auto` permits direct skill
improvements, while `propose` leaves suggestions for approval. You can watch,
steer, or stop the run in chat. Starting it does not enable automatic learning
or change settings. Like other sessions, it shares the agent's normal capacity.
**Learn from past conversations** opens a normal chat in which the agent reads
earlier conversations it can access, with its configured model and tools. You
can watch, steer, or stop it. Starting one does not change the learning mode.
<Warning>
Experience review and manual learning sessions can send conversation
content, including tool inputs and results, to the configured model provider.
Choose a provider and mode that match the workspace privacy and data-handling
requirements.
Background reviews and learning sessions send conversation content, including
tool inputs and results, to the configured model provider. Choose a provider
and mode that match the workspace's privacy and data-handling requirements.
</Warning>
## Review and revert learning
List and inspect every pending, applied, rejected, quarantined, or stale capture:
```bash
openclaw skills workshop list
openclaw skills workshop inspect <proposal-id>
```
Stop a pending capture from becoming active or quarantine it for safety review:
```bash
openclaw skills workshop reject <proposal-id> --reason "Too specific"
openclaw skills workshop quarantine <proposal-id> --reason "Needs security review"
```
Use `/learn` when you want an explicit proposal from the current conversation or
named sources:
```text
/learn
/learn docs/runbook.md; focus on recovery
```
`/learn` first revises a matching pending proposal or updates a matching live
skill. It creates a new pending proposal only when no skill owns the procedure,
and never auto-applies the result.
To learn from older work, open **Plugins -> Workshop** and select
**Learn from past conversations**. The new chat follows your current Workshop
mode and shows the agent's work and results.
## Configuration reference
| Setting | Default | Effect |
| --------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------- |
| `skills.workshop.autonomous.mode` | `"auto"` | Chooses capture behavior; `auto` also enables weekly collection review. |
| `skills.workshop.approvalPolicy` | `"auto"` | Controls prompts for normal agent-initiated lifecycle calls. It never expands the isolated reviewer tool surface. |
| `skills.workshop.maxPending` | `50` | Caps pending and quarantined proposals per agent. |
| `skills.workshop.maxSkillBytes` | `40000` | Caps proposal body size in bytes. |
See [Skills config](/tools/skills-config#workshop-skills-workshop) for ranges and
the complete `skills.*` schema.
## Troubleshooting
### No capture appears
### Nothing is learned
Check the following:
1. `skills.workshop.autonomous.mode` is `auto` in the active Gateway config.
2. The session did at least 10 model iterations since its last review, without
provider or prompt errors.
3. The session is an ordinary conversation, not cron, heartbeat, hook,
subagent, or Incognito.
4. Tool policy allows `skill_workshop` and the session is not sandboxed. In
`auto` mode, `openclaw doctor` reports the setting that hides it.
5. The Gateway stayed up and idle for the 30-second wait.
1. `skills.workshop.autonomous.mode` is `propose` or `auto` in the active Gateway
config.
2. The turn reached at least 10 model iterations without ending in a provider or
prompt error.
3. The conversation is eligible foreground work.
4. The runtime reported the resolved model and actual `skill_workshop`
availability.
5. Tool policy permits Workshop. Automatic maintenance also needs normal file
access. An enabled sandbox must expose its Workshop directory as writable.
6. The Gateway stayed running and idle through the 30-second quiet period.
A review that ran can still decide nothing is worth saving. That is the
expected result for routine work.
An eligible experience review can still abstain. No proposal is the expected
result when the evidence does not clear the reusable-procedure bar.
Use `openclaw skills curator status` to inspect experience review outcomes and
live skill usage. Current weekly collection results are in automation run history.
That CLI retains only the earlier collection records. It does not archive or
expire skills by age. The `curator pin`, `unpin`, and `restore` commands return an
error explaining that weekly collection review manages the skill collection.
### Too many or unwanted changes
### Doctor reports that Workshop is hidden
In `propose` and `auto` modes, `openclaw doctor` checks whether the default agent
tool policy permits `skill_workshop`. Apply the reported `tools.allow` or
`tools.alsoAllow` change, or set the autonomous mode to `off`.
### A proposal remains pending in auto mode
Automatic apply runs once. Inspect the proposal and its scanner state:
```bash
openclaw skills workshop inspect <proposal-id>
```
A normal write failure leaves it pending for manual review. A critical scanner
result moves it to quarantine. Fix the cause and apply manually. Do not build a
retry loop around automatic capture.
### Too many low-value captures appear
Switch to `propose` to review every capture, or `off` to disable autonomous
capture:
```bash
openclaw config set skills.workshop.autonomous.mode propose
openclaw config set skills.workshop.autonomous.mode off
```
Existing proposals and applied skills remain visible after the mode changes.
Undo individual changes with "undo", the Control UI **Undo** button, or
`openclaw skills workshop restore <name>`. Archive a skill you do not want with
`openclaw skills workshop archive <name>`. To stop background learning, set the
mode to `off`.
## Related
- [Skill Workshop](/tools/skill-workshop) for proposal lifecycle and storage
- [Creating skills](/tools/creating-skills) for hand-authored skills
- [Skills config](/tools/skills-config) for every `skills.*` setting
- [Skills CLI](/cli/skills) for Workshop commands
- [Skill Workshop](/tools/skill-workshop) for the tool, storage, undo, and unused-skill cleanup
- [Creating skills](/tools/creating-skills) for hand-written skills
- [Skills config](/tools/skills-config#workshop-skills-workshop) for `skills.workshop`
- [Skills CLI](/cli/skills#skill-workshop) for `openclaw skills workshop`

View file

@ -1,86 +1,268 @@
---
summary: "Index of the Skill Workshop documentation, one page per reader job"
summary: "Skills your agent learns on its own: how they are saved, undo, unused-skill cleanup, /learn, config, storage, and operator surfaces"
read_when:
- You want the agent to create or update a skill from chat
- You need to review, apply, reject, or quarantine a generated skill draft
- You are configuring Skill Workshop approval, autonomy, storage, or limits
- You are looking for the Skill Workshop page that matches your task
- You want to know how your agent saves and updates its own skills
- You want to undo, archive, or restore a learned skill
- You are turning Skill Workshop learning on or off
title: "Skill Workshop"
sidebarTitle: "Skill Workshop"
---
Skill Workshop is OpenClaw's governed path for creating and updating its own
generated skills. Through this path, agents and operators create a **proposal** (pending
draft with content, target binding, scanner state, hashes, and rollback
metadata) that becomes a live skill only when applied.
Skill Workshop holds the skills an agent writes for itself, called **learned
skills**. The agent saves a procedure after hard multi-step work and fixes a
learned skill that misled it; skills nobody uses for 30 days are archived. Every
change applies immediately, saves the previous version first, and can be undone.
Automatic background learning and weekly collection review instead maintain the
Workshop directory with normal agent file tools. These direct edits do not create
proposals or automatic rollback snapshots. Choose `propose` mode when each new
skill change needs review before publication.
Learned skills belong to one agent and are always visible to it: they bypass
`agents.defaults.skills` and `agents.entries.<id>.skills` allowlists. To hide
one, archive it.
By default, Skill Workshop writes only under the active agent's
`<state-dir>/agents/<agentId>/agent/workshop-skills`. When `agents.entries.<id>.agentDir` is
configured, it writes under `<agentDir>/workshop-skills` instead. Operators edit
bundled, plugin, ClawHub, extra-root, managed, personal-agent, project, and
workspace skills through their owning tools or files. The same authoring tool
also supports [personal library skills](/tools/skills#personal-skills-on-a-shared-gateway)
when the Gateway supplies an authorized library target. Those operations publish
managed revisions rather than Workshop proposals.
Skills you write yourself (workspace, project, managed, ClawHub, plugin, and
bundled skills) are not Workshop skills. Edit them at their source; see
[Creating skills](/tools/creating-skills). For profile-owned skills on a shared
Gateway, see [Personal library authoring](/tools/skill-workshop/personal-library).
Workshop storage is installation-managed and separate from the session
workspace and managed skill library. `OPENCLAW_STATE_DIR` selects the state
directory. `~/.openclaw` is the default.
## How the agent learns
This page is an index. Skill Workshop is documented on eight pages, one per
reader job. Open the page that matches your task.
- **During a turn:** when a learned skill the agent used turns out wrong or
incomplete, it views the skill and patches the misleading step. After hard
multi-step work you are likely to repeat, it saves the working procedure,
patching the skill that covers that kind of task or creating one when none does.
- **Background review:** after enough model work in a conversation, or right
after a turn that used a learned skill, a background run reviews it and saves
anything worth keeping. See [Self-learning](/tools/self-learning).
- **`/learn [request]`:** asks the agent to save a skill now, from the current
conversation or from sources you name. See [`/learn`](#learn).
- **Learn from past conversations:** the Control UI button opens a normal chat
in which the agent reviews earlier conversations and saves what it finds.
- **Unused-skill cleanup:** learned skills nobody used for 30 days are archived.
See [Unused-skill cleanup](#unused-skill-cleanup).
| Page | Read it when |
| -------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
| [Personal library authoring](/tools/skill-workshop/personal-library) | You are authoring a profile-owned personal skill on a shared Gateway. |
| [How Skill Workshop works](/tools/skill-workshop/how-it-works) | You need the proposal rules, the Control UI review surfaces, or the state diagram. |
| [Collection review](/tools/skill-workshop/collection-review) | You are enabling weekly review, or recovering a skill from a retained backup. |
| [Chat and CLI authoring](/tools/skill-workshop/authoring) | You are creating, revising, or applying a proposal from chat or the CLI. |
| [Proposal content, support files, and the agent tool](/tools/skill-workshop/proposals) | You are writing `PROPOSAL.md`, adding support files, or calling `skill_workshop`. |
| [Self-learning and approval settings](/tools/skill-workshop/configuration) | You are configuring autonomy, approval policy, or Workshop size caps. |
| [Gateway methods, storage, and limits](/tools/skill-workshop/reference) | You need the Gateway method list, the on-disk layout, or the hard limits. |
| [Skill Workshop troubleshooting](/tools/skill-workshop/troubleshooting) | A proposal fails, does not appear, or the agent cannot call the tool. |
Changed skills load in new sessions. A running session keeps the skill snapshot
it started with.
## Where each section moved
<a id="changes-and-recovery" />
Every section heading id from the previous single-page version keeps its
anchor here, so an existing link such as
`/tools/skill-workshop#collection-review` still resolves. Each entry points at
the page that now holds the content.
## Undo
- <a id="personal-library-authoring" />[Personal library authoring](/tools/skill-workshop/personal-library#personal-library-authoring)
- <a id="how-it-works" />[How it works](/tools/skill-workshop/how-it-works#how-it-works)
- <a id="review-in-the-control-ui" />[Review in the Control UI](/tools/skill-workshop/how-it-works#review-in-the-control-ui)
- <a id="lifecycle" />[Lifecycle](/tools/skill-workshop/how-it-works#lifecycle)
- <a id="collection-review" />[Collection review](/tools/skill-workshop/collection-review#collection-review)
- <a id="changes-and-recovery" />[Changes and recovery](/tools/skill-workshop/collection-review#changes-and-recovery)
- <a id="when-an-older-backup-cannot-be-restored-automatically" />[When an older backup cannot be restored automatically](/tools/skill-workshop/collection-review#when-an-older-backup-cannot-be-restored-automatically)
- <a id="chat" />[Chat](/tools/skill-workshop/authoring#chat)
- <a id="learn-from-recent-work" />[Learn from recent work](/tools/skill-workshop/authoring#learn-from-recent-work)
- <a id="cli" />[CLI](/tools/skill-workshop/authoring#cli)
- <a id="plugin-evaluation-and-lifecycle-hooks" />[Plugin evaluation and lifecycle hooks](/tools/skill-workshop/proposals#plugin-evaluation-and-lifecycle-hooks)
- <a id="proposal-content" />[Proposal content](/tools/skill-workshop/proposals#proposal-content)
- <a id="support-files" />[Support files](/tools/skill-workshop/proposals#support-files)
- <a id="agent-tool" />[Agent tool](/tools/skill-workshop/proposals#agent-tool)
- <a id="self-learning" />[Self-learning](/tools/skill-workshop/configuration#self-learning)
- <a id="scan-past-sessions" />[Scan past sessions](/tools/skill-workshop/configuration#scan-past-sessions)
- <a id="approval-and-autonomy" />[Approval and autonomy](/tools/skill-workshop/configuration#approval-and-autonomy)
- <a id="gateway-methods" />[Gateway methods](/tools/skill-workshop/reference#gateway-methods)
- <a id="storage" />[Storage](/tools/skill-workshop/reference#storage)
- <a id="limits" />[Limits](/tools/skill-workshop/reference#limits)
- <a id="troubleshooting" />[Troubleshooting](/tools/skill-workshop/troubleshooting#troubleshooting)
- <a id="tool-policy-diagnostic" />[Tool-policy diagnostic](/tools/skill-workshop/troubleshooting#tool-policy-diagnostic)
When a background run changes a skill, OpenClaw posts one line to the
conversation that triggered it:
```text
💾 Learned: updated `deploy-staging` (tightened the rollback step). Say "undo" to revert this skill change.
```
Reply "undo" and the agent restores the previous version with `skill_workshop`.
Nothing is posted when the review changed nothing. Channel-less Control UI
sessions get the same line as a transcript entry.
You can also undo from the Control UI (**Undo** next to a recent change) or the
CLI:
```bash
openclaw skills workshop restore deploy-staging
openclaw skills workshop restore deploy-staging --version <version-id>
```
Restore saves the current copy before replacing it, so an undo can itself be
undone. A skill that was just created has no earlier version; archive it instead.
<a id="collection-review" />
<a id="weekly-curator" />
## Unused-skill cleanup
When learning is on, OpenClaw archives a learned skill with no activity for 30
days, with the reason `unused for 30 days` and actor `curator` in the change
feed. Activity is the latest of: a recorded read of its `SKILL.md`, a
foreground `skill_workshop` `view`, and its last change (create, patch,
restore). A skill younger than 30 days is never archived. The check runs at
most once a day per agent, after a finished turn, and never delays the turn.
Archive is the normal versioned archive: restore it any time.
Cleanup only runs for agents whose default runtime is the embedded OpenClaw
harness, in the Gateway process. The Codex app-server harness reads skills with
its native shell, which OpenClaw cannot attribute to a skill, so cleanup stays
off for Codex agents rather than archiving skills that are in use.
Earlier versions ran a weekly curator automation
(`skill-collection-review:<agentId>`). It is retired: the Gateway deletes those
cron rows on upgrade and creates no replacement.
<a id="learn" />
## `/learn`
```text
/learn
/learn docs/runbook.md; focus on recovery
```
`/learn` is a normal foreground turn. With no request, the agent saves the
reusable workflow from the current conversation. With a request, it gathers the
named paths, URLs, notes, or conversation references with its normal tools and
honors any focus, scope, or naming you give. It views related skills first,
patches the one that covers the task, and creates a new skill only when none
does. Related skills that cover the same class of task get merged into one
umbrella skill: it patches the survivor and archives the rest with
`absorbed_into`. Then it tells you which skill changed. If there is nothing
durable to learn, it changes nothing.
`/learn` works in both learning modes. It replies with an explanation instead
when `skill_workshop` is unavailable, for example in a sandboxed session or
when tool policy hides the tool.
## Agent tool
The built-in `skill_workshop` tool is how every learned-skill change is made.
It is part of `tools.profile: "coding"`; with a stricter policy, add it to
`tools.allow` or `tools.alsoAllow`.
| Action | Parameters | Effect |
| ------------- | ---------------------------------------------------- | ------------------------------------------------------------------------ |
| `list` | — | Lists live skills and archived skills |
| `view` | `name`, optional `file_path`, `version` | Reads a file, current or from a saved version |
| `create` | `name`, `content` (full `SKILL.md`) | Creates a new skill |
| `patch` | `name`, `old_text`, `new_text`, optional `file_path` | Replaces one exact, unique span |
| `write_file` | `name`, `file_path`, `content` | Writes a support file, or rewrites `SKILL.md` |
| `remove_file` | `name`, `file_path` | Deletes one support file; `SKILL.md` goes only through `archive` |
| `archive` | `name`, optional `absorbed_into`, `reason` | Hides the skill; `absorbed_into` names the live skill that now covers it |
| `restore` | `name`, optional `version` | Restores the newest saved version, or the one named |
Every mutating action accepts `reason`, one short line that appears in the
change feed and the chat notice. Every change saves the previous version first.
Writes are validated before they land:
- Names use 1-63 lowercase letters, digits, or hyphens and start with a letter
or digit.
- `SKILL.md` needs frontmatter whose `name` matches the skill directory and a
`description` of 1-1024 bytes (aim for about 160). It must fit within `skills.workshop.maxSkillBytes`.
- Support files go under `references/`, `templates/`, `scripts/`, or `assets/`,
up to 256 KiB each. Absolute paths, traversal, and symlinks are refused.
- A critical security-scanner finding, including a literal secret, refuses the
write and names the file, line, and rule.
The background review must `view` an existing skill before it can `patch`,
`write_file`, `remove_file`, or `archive` it, and its archives need
`absorbed_into` or `reason`. A foreground `view` counts as using the skill.
A successful `create`, `patch`, or `write_file` of `SKILL.md` may end with up to
three `Advisory (not blocking)` lines: authoring issues the write introduced (a
description over 160 bytes or opening with "This skill", a body over 250 lines
or 12 KB, emphasis words, three or more Never/Don't steps, update notes or
dates). The write has already landed. A `create` also lists the agent's other
learned skills, so the agent itself decides whether the new skill duplicates
one and should be merged.
## Configuration
```json5
{
skills: {
workshop: {
autonomous: { mode: "auto" },
maxSkillBytes: 40000,
},
},
}
```
| Setting | Default | Effect |
| --------------------------------- | -------- | --------------------------------------------------------------------------------------- |
| `skills.workshop.autonomous.mode` | `"auto"` | `"auto"` enables the background review and unused-skill cleanup. `"off"` disables both. |
| `skills.workshop.maxSkillBytes` | `40000` | Maximum `SKILL.md` size in bytes (1024-200000). |
```bash
openclaw config set skills.workshop.autonomous.mode off
openclaw config set skills.workshop.autonomous.mode auto
```
With `off`, the agent can still create and update learned skills when you ask,
through `/learn`, or in a **Learn from past conversations** session. See
[Skills config](/tools/skills-config#workshop-skills-workshop) for the schema.
## Where files live
```text
<agentDir>/workshop-skills/
<name>/
SKILL.md
references/ templates/ scripts/ assets/
.archive/
<name>/<versionId>/ # full copy of the skill before each change
```
`<agentDir>` defaults to `<state-dir>/agents/<agentId>/agent`, or
`agents.entries.<id>.agentDir` when set. `<state-dir>` is `~/.openclaw` unless
`OPENCLAW_STATE_DIR` overrides it.
A version is saved before every change, including archive and restore. The
newest 10 versions per skill are kept. An archived skill has no live directory;
its newest version restores it. The change feed (who changed which skill, when,
and why) lives in the state database and keeps the newest 500 entries per agent.
## Operator surfaces
- **Control UI:** open **Plugins → Skill workshop** to see learned skills with
their use counts, recent changes with **Undo**, a file viewer with saved
versions, archive and restore, the learning mode switch, and
**Learn from past conversations**.
- **CLI:** `openclaw skills workshop list | changes | show | archive | restore`.
See [Skills CLI](/cli/skills#skill-workshop).
- **Plugins:** the [`skill_changed`](/plugins/hooks/reference#skill-lifecycle)
hook observes each committed Workshop change.
Gateway methods take an optional `agentId` (default agent when omitted):
| Method | Scope | Params | Returns |
| ------------------------- | ---------------- | ------------------------------- | ----------------------------------------------------------------------------------------------- |
| `skills.workshop.list` | `operator.read` | — | `agentId`, `mode`, `root`, live `skills` (with `useCount`, `lastUsedAtMs`), `archived` versions |
| `skills.workshop.changes` | `operator.read` | `limit` (up to 500), `beforeMs` | `changes`, newest first |
| `skills.workshop.read` | `operator.read` | `name`, `filePath`, `versionId` | `name`, `filePath`, `content`, `files` |
| `skills.workshop.archive` | `operator.admin` | `name`, `reason` | `change` |
| `skills.workshop.restore` | `operator.admin` | `name`, `versionId` | `change` |
Archive and restore from the CLI, Control UI, or Gateway are recorded as `user`
changes.
<a id="when-an-older-backup-cannot-be-restored-automatically" />
## Upgrading from earlier releases
Earlier releases staged learned skills as drafts for review. `openclaw doctor
--fix` exports any pending drafts and removes the old settings; see
[State migrations](/cli/doctor/state-migrations) and
[Skills config](/tools/skills-config#workshop-skills-workshop). Exported drafts
are not loaded. Ask the agent to save one with `/learn` if you still want it.
The earlier `skills.proposals.*` and `skills.curator.*` Gateway methods stay
registered but return an error that points to the methods above. The
`skill_proposal_evaluate` and `skill_proposal_changed` plugin hooks were removed;
see [Removed surfaces](/plugins/sdk-migration/removed-surfaces#skill-workshop-proposal-hooks).
Backups written by the earlier weekly review under
`<agentDir>/skill-workshop/collection-backups/` are no longer read. Copy any
files you need from them by hand; OpenClaw does not restore them.
## Troubleshooting
| Problem | Check |
| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Nothing is ever learned | `skills.workshop.autonomous.mode` is `auto`, the conversation is eligible, and tool policy allows `skill_workshop`. See [Self-learning](/tools/self-learning#troubleshooting). |
| Agent cannot call `skill_workshop` | Sandboxed runs do not get the tool. Use a non-sandboxed session or the CLI. Otherwise add the tool to `tools.allow` or `tools.alsoAllow`. |
| A write is refused | The error names the fix: rename the skill, correct the frontmatter, shorten the description or `SKILL.md`, or remove the flagged line. |
| An unwanted change was made | Say "undo", press **Undo** in the Control UI, or run `openclaw skills workshop restore <name>`. |
| A skill was archived unexpectedly | Unused-skill cleanup archives skills with no activity for 30 days. Run `openclaw skills workshop restore <name>`. |
In `auto` mode, `openclaw doctor` runs the `core/doctor/skill-workshop-tool-policy`
check for each agent. It names the sandbox setting or the config layer that
hides `skill_workshop` and the exact `allow` or `alsoAllow` change to make.
## Related
- [Skills](/tools/skills) for load order, precedence, and visibility
- [Self-learning](/tools/self-learning) for conservative post-run skill proposals
- [Self-learning](/tools/self-learning) for the background review
- [Skills](/tools/skills) for load order and visibility
- [Creating skills](/tools/creating-skills) for hand-written `SKILL.md`
basics
- [Skills config](/tools/skills-config) for the full `skills.workshop` schema
- [Skills CLI](/cli/skills) for `openclaw skills` commands
- [Skills config](/tools/skills-config#workshop-skills-workshop) for the `skills.workshop` schema
- [Skills CLI](/cli/skills#skill-workshop) for `openclaw skills workshop`

View file

@ -1,119 +0,0 @@
---
summary: "Create, revise, and close out Workshop proposals from chat or the CLI"
title: "Chat and CLI authoring"
read_when:
- You want the agent to create or update a skill from chat
- You are using /learn to capture recent work
- You need the openclaw skills workshop commands and flags
---
## Chat
For Workshop authoring, ask the agent for the skill you want; it calls
`skill_workshop` and returns a proposal id. Personal library authoring instead
returns the managed publication receipt described in
[Personal library authoring](/tools/skill-workshop/personal-library).
### Learn from recent work
Use `/learn` to route the current conversation or named sources into the best
matching pending proposal or live skill, creating a skill only when needed:
```text
/learn
/learn docs/runbook.md and https://example.com/guide; focus on recovery
```
With no request, `/learn` asks the agent to distill the reusable workflow from
the current conversation. With a request, the agent treats paths, URLs, pasted
notes, and conversation references as sources while honoring focus, scope, and
naming requirements. It gathers the sources with its existing tools, then calls
`skill_workshop` to revise a matching pending proposal, update a matching live
skill, or create a proposal when neither exists.
The resulting proposal stays `pending`; `/learn` never applies it. Review and
apply it through the normal approval flow or with `openclaw skills workshop`.
When the actual turn supports only personal publication, including paired-node
personal CLI authoring, `/learn` stops without changing a skill. Ask normally
for explicit personal creation if you want to publish a revision, or use the
existing administrator UI or CLI for Workshop proposal review. Personal turns
cannot stage a pending draft.
Create:
```text
Make a skill called morning-catchup that runs my Monday inbox routine.
```
Update an existing Workshop-generated skill:
```text
Update trip-planning to also check seat maps before booking.
```
If a skill used in the current turn proves wrong or incomplete, the agent reads
the live skill and creates a targeted patch proposal. When the complete skill
does not fit the selected model's read budget, the agent can prepare one unique
exact span and review its bounded surrounding context before patching it. A
runtime receipt limits this flow to skills used in that run. Autonomous mode
`off` disables repair, `propose` leaves the patch pending until explicitly
applied, and `auto` scans and applies it immediately. The repaired skill is
loaded by new sessions; the running session keeps its original skill snapshot.
Iterate on a pending proposal:
```text
Show me the morning-catchup proposal.
Revise it to also flag anything marked urgent.
Apply the morning-catchup proposal.
```
Agent-initiated `apply`, `reject`, and `quarantine` run without an additional
approval prompt by default. Set `skills.workshop.approvalPolicy` to `"pending"`
to require operator approval before those actions.
When approval is required, the prompt identifies the proposal id and target
skill, and shows the proposal description, support-file count, and body size.
Approval requests are bounded to finish before the agent tool watchdog. If no
decision arrives before the prompt expires, the lifecycle action does not run:
the proposal stays pending and unchanged. Decide later in the Skill Workshop UI or run
`openclaw skills workshop apply|reject|quarantine <proposal-id>`. Agents should
not retry an expired lifecycle action in a loop.
## CLI
```bash
# Create
openclaw skills workshop propose-create \
--name morning-catchup \
--description "Daily inbox catch-up: triage, archive, surface, draft, plan" \
--proposal ./PROPOSAL.md
# Update an existing Workshop-generated skill
openclaw skills workshop propose-update trip-planning --proposal ./PROPOSAL.md
# List and inspect
openclaw skills workshop list
openclaw skills workshop inspect <proposal-id>
# Revise before approval
openclaw skills workshop revise <proposal-id> --proposal ./PROPOSAL.md
# Run installed plugin evaluators against the exact current draft
openclaw skills workshop evaluate <proposal-id>
# Close out
openclaw skills workshop apply <proposal-id>
openclaw skills workshop reject <proposal-id> --reason "Duplicate"
openclaw skills workshop quarantine <proposal-id> --reason "Needs security review"
```
Every subcommand takes `--agent <id>` (agent context; defaults to
cwd-inferred, then the default agent) and `--json` (structured output).
Proposals and generated skill targets are scoped to the selected agent.
`propose-create`, `propose-update`, and `revise` also take `--goal <text>` and
`--evidence <text>` to record proposal context alongside `--proposal`.
`evaluate` runs through the live Gateway plugin registry, snapshots the current
proposal revision before dispatch, and accepts `--correlation-id <id>` for external
orchestration.

View file

@ -1,113 +0,0 @@
---
summary: "Weekly Workshop collection review, its change semantics, and backup recovery"
title: "Collection review"
read_when:
- You are enabling or debugging the weekly collection review
- You need to know what a review can change and what it cannot roll back
- You are recovering a Workshop skill from a retained collection backup
---
## Collection review
In `auto` mode, the Gateway maintains one weekly automation per agent. It is
a normal isolated agent turn: cron owns scheduling, cancellation, and run
history. `propose` and `off` disable these reviews.
The reviewer reads and edits the agent's Workshop directory with normal file
tools. Directory listings are paged to fit the selected model instead of putting
every file path into the initial prompt. The reviewer follows each continuation
before changing that directory.
Skill contents are review material, not active instructions. It keeps useful
procedures, simplifies bloated skills, consolidates overlap, and removes obsolete
files. Absence of use in the current run never justifies removal. Usage tracking
and experience review remain active; weekly cleanup does not receive a separate
usage table.
The file tools stay rooted at the Workshop directory. Shell commands use the
operator's existing cron execution and approval policy; enabling review does not
grant additional shell access. An approval-required policy can refuse unattended
shell commands; a full-access policy permits them. File discovery does not need a shell.
Reviews support the embedded runtime and CLI runtimes that declare instruction
isolation, disable their native tools, and use only the Gateway's restricted
OpenClaw tool set, including Claude CLI. These CLI reviews retain the host-selected instruction snapshot;
Workshop skill contents remain review material. OpenClaw carries the Workshop
file root and prepared sandbox to the mediated tools. Changing the CLI working
directory alone does not provide containment.
Runtimes without those guarantees, including undeclared CLI backends, the Codex harness, and
node-placed CLI execution, remain unsupported for rooted reviews. When the configured
model chain is provably unsupported, the weekly job remains visible but disabled
with `no-rooted-runtime` in its display name. Configure a supported runtime or fallback;
Gateway startup and config reload reconcile enablement and clear that reason.
Reconciliation uses prepared policy and processes one agent at a time, yielding
between updates so fleet-wide review preparation does not block Gateway health requests.
Stored session model or runtime preferences are preserved; reviews with these
preferences remain enabled because configuration alone cannot prove their execution path.
If eligibility cannot be determined statically, the job remains enabled and runtime
admission still rejects unsupported execution with an explanation. No runtime is
substituted to bypass that check. If an enabled sandbox has
`workspaceAccess: "ro"` or `"none"`, the turn refuses to run rather than editing
a disposable copy. A writable sandbox uses the agent's Workshop directory.
Sandbox backends must support directory reads to provide shell-free discovery.
Bundled backends use their existing filesystem permissions for these reads.
### Changes and recovery
Collection review follows normal agent file-edit semantics. Completed edits
remain if a later step fails or the turn is cancelled. There is no collection-wide
transaction, post-turn scanner, automatic rollback, or separate review history
writer. This also prevents a failed review from restoring an old tree over
concurrent operator edits. Per-skill proposal validation, scanning, and apply
behavior described in [How Skill Workshop works](/tools/skill-workshop/how-it-works)
are unchanged.
The reviewer ends with a summary of changes and removal reasons, or why no change
was needed. Find it in the automation's run history. Reviews do not announce into
a conversation. Future sessions load changed skills; running sessions retain
their existing instruction snapshot.
Existing collection backups are preserved. The `restore_collection` action
can restore a retained backup from the previous review implementation, but new
reviews do not create collection backups. The `history` action reads those
historical review records; current results belong to automation history.
Restore refuses to overwrite affected skills changed after that backup.
### When an older backup cannot be restored automatically
Restore also refuses when it cannot completely read the included content in a
current result tree or its saved original, including content beyond sixteen path
components. This leaves the current skill files and retained backup intact. Older
backups may contain hashes that omitted deeper files, so deleting that content
from the current tree cannot make the backup verifiable. A skill dropped by the
cleanup can still be restored to its absent path, including deep support files.
Do not edit backup hashes or delete or flatten live files merely to make restore pass.
For operator-led recovery:
1. Pause writes to the agent's Workshop, including collection review, before comparing or restoring files.
2. Locate the backup under
`<agentDir>/skill-workshop/collection-backups/<backup-id>/`.
Its `manifest.json` identifies the affected Workshop-relative directories in
`skillDirs` and `resultSkillDirs`.
3. Create a new private inspection directory outside the Workshop and state
directory. Copy the entire backup directory, including `manifest.json` and
all saved content, into it. Current backups use `skills/`; history-only imports
use `history/workspace/`. Separately copy each existing affected current directory
into a `current/` subtree, preserving its Workshop-relative path. Include hidden
files and all nested content. If any file cannot be copied, stop rather than use
a partial copy.
4. Compare the inspection copies to select the intended content. Keep the live
tree and original backup unchanged during review, and retain unedited copies
of both versions before carrying out any operator-approved recovery.
Retained legacy backups may instead live under
`<state-dir>/skill-workshop/collection-backups/<workspace-hash>/<backup-id>/`
and contain a `workspace/` subtree. Preserve that original layout in inspection
copies; do not rewrite the manifest to make an old backup look current.
Doctor retains the legacy source after importing a history-only copy. Once it
verifies a matching archive and saved workspace contents in a configured agent's
backup directory, later runs stop reporting that backup as pending, even if the
original workspace is no longer configured. Missing, incomplete, or mismatched
copies still require review; the original source remains intact.

View file

@ -1,96 +0,0 @@
---
summary: "Self-learning modes and the skills.workshop approval, autonomy, and size settings"
title: "Self-learning and approval settings"
read_when:
- You are configuring Skill Workshop approval, autonomy, or limits
- You want to understand where self-learning proposals are reviewed
- You are scanning past sessions from the Control UI
---
## Self-learning
After substantial work, a detached background review can turn corrections and
successful procedures into reusable Workshop skills; see
[Self-learning](/tools/self-learning). Set `skills.workshop.autonomous.mode` to
`propose` to create pending proposals, or to `auto` to maintain complete skills
with normal agent tools. The Control UI Workshop tab shows
whether self-learning is on; use the config setting to choose all three modes.
### Scan past sessions
The Control UI can review older work without enabling autonomous self-learning.
Open **Plugins → Workshop** and select **Learn from past conversations**. A normal
session opens with the mining instructions, using the selected agent's configured
model, permitted tools, existing skills, and accessible conversation history.
The agent decides what to read and whether the evidence warrants a skill change.
It follows the current Workshop mode: `auto` permits direct improvements and
`propose` leaves suggestions for approval. No separate scanner selects transcripts
or limits the run to a fixed number of conversations or proposals.
Watch, steer, or stop the work in chat. The session keeps its instructions and
outcome instead of separate coverage counters. A manual request does not change
the self-learning setting. Normal session capacity, permissions, provider pricing,
and data-handling terms apply.
In `propose` and `auto` modes, OpenClaw can review one finished substantial turn
after the agent system becomes idle. It records the finished turn's boundary and
reads that turn's model context asynchronously with the same provider and model.
Review transcript and session metadata stay detached from foreground work.
Incognito turns are excluded from automatic experience review.
In `propose` mode, only `skill_workshop` executes and the reviewer can stage one
pending mutation. In `auto` mode, ordinary file tools can inspect, edit, and
verify several connected files in the Workshop directory. The review inherits
source permissions and shell approvals. Its `process` tool cannot control
foreground jobs; the Workshop file root is not a shell sandbox.
A failed review is recorded after one attempt; completed direct edits remain.
See [Self-learning](/tools/self-learning) for enablement, eligibility, privacy and cost details,
the proposal threshold, and troubleshooting.
## Approval and autonomy
```json5
{
skills: {
workshop: {
autonomous: {
mode: "auto",
},
approvalPolicy: "auto",
maxPending: 50,
maxSkillBytes: 40000,
},
},
}
```
| Setting | Default | Effect |
| ----------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `autonomous.mode` | `"auto"` | `"off"` disables autonomous capture, `"propose"` creates pending proposals, and `"auto"` enables direct per-turn and weekly Workshop maintenance. |
| `approvalPolicy` | `"auto"` | `"auto"` skips an additional prompt for agent-initiated `apply`, `reject`, or `quarantine` (the agent still has to call the action). `"pending"` requires approval. |
| `maxPending` | `50` | Caps pending and quarantined proposals per agent (1-200). |
| `maxSkillBytes` | `40000` | Caps proposal body size in bytes (1024-200000). Autonomous proposals also have a 10,000-character cap; direct maintenance does not use proposal limits. |
The selected model reviews retained evidence before deciding whether a durable
procedure needs an update. Foreground work does not wait for that review. It
starts only when the foreground runtime reports its resolved model and actual
`skill_workshop` availability; restrictive or unknown tool policy fails closed.
In `auto` mode, the reviewer uses the same direct-maintenance guidance as weekly
review. File tools stay rooted at Workshop; shell commands retain the source
session's execution policy. Source deletion, replacement, or permission changes
invalidate retained review authority. Direct maintenance does not run a post-turn
proposal scanner or create rollback snapshots. Use backups for unwanted edits.
In `propose` mode, the reviewer can read or prepare an exact span before staging
one pending mutation. Existing-skill proposals retain read receipts, content-hash
binding, size validation, and normal apply-time scanning and rollback metadata.
Immediate foreground repair also retains the normal proposal apply path in
`auto` mode; it is separate from direct background maintenance.
See [Self-learning](/tools/self-learning) for the complete autonomous review behavior and safety
model.
Proposal descriptions are always capped at 160 bytes, independent of
`maxSkillBytes`.

View file

@ -1,88 +0,0 @@
---
summary: "Workshop proposal rules, Control UI review, and the proposal state diagram"
title: "How Skill Workshop works"
read_when:
- You want the proposal rules before applying a generated skill
- You are reviewing installed skills or suggestions in the Control UI
- You need the proposal state transitions
---
## How it works
The following lifecycle applies to Workshop proposals. When you ask your agent to
change a skill you own (repository skill source, the workspace `skills/`
directory, project `.agents/skills/`, or a configured extra skill directory), it
edits that file directly; Workshop is for skills the agent captures on its own.
Bundled, ClawHub-installed, and plugin-provided skills are replaced by their
owners' updates, so the agent says so instead of editing them.
- **Proposal first:** generated content is stored as `PROPOSAL.md`, not
`SKILL.md`.
- **Apply is the only live write:** create, update, and revise never change
active skills.
- **Directory-owned updates:** creates and updates stay inside
`<state-dir>/agents/<agentId>/agent/workshop-skills`. A skill is Workshop-owned
exactly when it is contained in that agent's directory.
- **No clobber:** create fails if the target already exists in that agent's
Workshop directory. Skills from other sources are never changed.
For same-named skills, [loading order](/tools/skills#loading-order) determines
which definition is used.
- **Hash bound:** update proposals bind to the current target hash and go
`stale` if the live skill changes before apply.
- **Scanner gated:** apply reruns the security scanner before writing. Only
critical findings block apply; warn-level findings remain visible but do not
block it. Prompt-related keywords are not scanner findings: mentioning hidden
instructions or tool approval does not establish an instruction override.
Literal credential rejection, approval policy, and blocking evaluator decisions
remain enforced.
- **Recoverable:** apply writes rollback metadata before touching live files.
- **Revision atomic:** create and revise flush a complete immutable proposal
generation, publish it with an atomic rename, then sync its parent directory
where supported before publishing the SQLite record and event together.
Process interruption exposes either the complete previous generation or the
complete new one.
- **Consistent surfaces:** chat, CLI, and Gateway all call the same service.
## Review in the Control UI
Open **Plugins → Workshop** and select the agent whose skills you want to inspect.
- **Skills** opens by default and lists the skills currently installed in that
agent's Workshop directory. Skills with instruction changes appear first.
Select one to read its complete current instructions. Changed skills show
additions and removals inline with all unchanged sections in one scrollable
comparison. Unchanged skills show their current instructions as Markdown.
Current instructions remain readable while saved versions are compared.
- **Suggestions** contains pending proposals that you can evaluate, revise,
apply, or reject.
Action confirmations dismiss automatically. A warning that a suggestion changed
stays visible so you can review the updated draft.
Past applied, rejected, quarantined, and stale proposals remain available through
CLI and Gateway inspection. They are not listed as a separate Control UI section
or counted as installed skills.
Comparisons use retained applied versions, not a complete edit timeline.
Relative dates identify the saved baseline, not when later edits occurred.
Supporting files and frontmatter are not compared. Missing versions are labeled;
the complete current instructions remain readable without a saved version.
No historical content is reconstructed.
Removing an installed skill does not remove its proposal history. Reading a
historical draft does not restore or reinstall it. Handwritten and externally
installed skills remain on their owning skills surfaces.
## Lifecycle
```text
create/update -> pending
revise -> pending
evaluate -> pending
apply -> applied
reject -> rejected
quarantine -> quarantined
target change -> stale
```
Only a `pending` proposal can be revised, applied, rejected, or quarantined.

View file

@ -1,158 +0,0 @@
---
summary: "Proposal file format, support-file rules, plugin evaluation, and skill_workshop parameters"
title: "Proposal content, support files, and the agent tool"
read_when:
- You are writing PROPOSAL.md or its support files
- You are building a plugin evaluator or lifecycle hook
- You need the skill_workshop action and parameter reference
---
## Plugin evaluation and lifecycle hooks
Gateway plugins can extend Skill Workshop without owning proposal storage or
live skill writes:
- `skill_proposal_evaluate` receives an exact candidate bundle and, for update
proposals, the complete baseline skill. It returns attributed findings,
metrics, and an optional `pass`, `revise`, or `block` decision.
- `skill_proposal_changed` observes durable `created`, `revised`,
`evaluation_completed`, `applied`, `rejected`, `quarantined`, and `stale`
events.
- `skill_changed` observes committed live skill `created`, `updated`, and
`removed` events from Workshop and supported install/uninstall paths.
Evaluations are explicit from the CLI, Control UI, Gateway
`skills.proposals.evaluate` method, or agent `skill_workshop` action. Results
are stored on the exact proposal revision and in the append-only proposal event
ledger. Evaluator failures remain attributed results; only a completed
`decision: "block"` prevents apply. Apply also revalidates the evaluated target
tree, so any live skill asset drift requires a fresh evaluation.
The lifecycle supports external optimization loops without embedding one.
Controllers can consume `skills.proposals.events.list`, evaluate an exact
`revisionHash`, revise with `expectedRevisionHash` and `correlationId`, then continue
from the returned event sequence. OpenClaw does not schedule, auto-revise, or
decide when such a loop should stop.
## Proposal content
While pending, the proposal is stored as `PROPOSAL.md` with proposal-only
frontmatter:
```markdown
---
name: "morning-catchup"
description: "Daily inbox catch-up: triage, archive, surface, draft, plan"
status: proposal
version: "v1"
date: "2026-05-30T00:00:00.000Z"
---
```
On apply, Skill Workshop writes the active `SKILL.md` and removes the
proposal-only fields: `status`, proposal `version`, and proposal `date`.
## Support files
Use `--proposal-dir` when the proposed skill needs files beside
`PROPOSAL.md`:
```bash
openclaw skills workshop propose-create \
--name weekly-update \
--description "Friday wrap-up: stats, highlights, next week's top three" \
--proposal-dir ./weekly-update-proposal
```
The directory must contain `PROPOSAL.md`. Support files must live under
`assets/`, `examples/`, `references/`, `scripts/`, or `templates/`. Skill
Workshop scans, hashes, and stores them with the proposal, then writes them
beside the live `SKILL.md` only on apply.
Rejected support-file paths: absolute paths, hidden path segments, path
traversal, overlapping paths, executable files, non-UTF-8 text, null bytes,
and paths outside the standard support folders.
Directory drafts must be completely readable and fit within eight path
components, including the filename. Evaluator bundles require all included target
content to be readable and within sixteen path components. Root `.clawhub`,
`.clawdhub`, and `.openclaw` metadata entries are excluded; those names nested
elsewhere remain included. Unreadable included directories or deeper content
produce an error. Fix the reported directory or reduce its nesting, then retry.
For a collection restore failure, follow the
[manual recovery guidance](/tools/skill-workshop/collection-review#when-an-older-backup-cannot-be-restored-automatically)
instead of restructuring the live tree.
## Agent tool
For personal library operations, `skill_workshop` exposes
`list | read | create | update | share | unshare | transfer | activate | remove | rollback`.
The Gateway chooses the authorized namespace. When Workshop authoring is also
available, `target: "personal"` selects the personal library. Reads return a
stable skill ID and revision. Personal `list` takes only `action` and `target`;
it returns up to 20 entries and points to My skills for the complete library.
Use an entry's `skillId` as `skill_id` for `read`, not its name or slug.
Personal operations do not accept Workshop-only fields such as `query`, `limit`,
or `skill_name`, or proposal actions such as `inspect` and `prepare_patch`.
Updates require `skill_id` and `expected_revision`;
omit `proposal_content` to preserve the instructions. Use `files` for named
support-file upserts and `delete_files` for explicit removals. Unmentioned
support files are preserved. Large instructions are returned whole or explicitly
omitted with directions to the operator workflow; binary supporting content is
not injected into model context.
For Workshop proposals, omit `target`. The tool uses one required `action`:
`create | read | prepare_patch | patch | update | revise | list | inspect | evaluate | apply | reject | quarantine | history | restore_collection`.
Other Workshop parameters apply depending on the action:
| Parameter | Used by | Notes |
| -------------------------- | ---------------------------------------------------------------- | --------------------------------------------------------------------- |
| `name` | `create`, `inspect`, `revise` | Required for `create`; resolves a pending proposal by name otherwise |
| `description` | `create`, `update`, `revise` | Max 160 bytes |
| `skill_name` | `read`, `prepare_patch`, `patch`, `update` | Existing skill name or key |
| `old_string` | `prepare_patch`, `patch` | Exact current text; prepare it when the complete skill cannot be read |
| `new_string` | `patch` | Replacement for the exact current text |
| `proposal_content` | `create`, `update`, `revise` | Required for create/update; omit on revise to preserve the body |
| `support_files` | `create`, `update`, `revise` | Array of `{ path, content }` |
| `goal`, `evidence` | `create`, `update`, `revise` | Free-text context |
| `proposal_id` | `inspect`, `revise`, `evaluate`, `apply`, `reject`, `quarantine` | Target proposal |
| `artifact_path` | `inspect` | `PROPOSAL.md` or one listed support-file path |
| `expected_revision_hash` | `evaluate`, `apply`, `reject`, `quarantine` | Rejects a stale orchestration step |
| `correlation_id` | `evaluate`, `revise`, `apply`, `reject`, `quarantine` | External run or experiment correlation |
| `reason` | `apply`, `reject`, `quarantine` | Optional |
| `query`, `status`, `limit` | `list` | Filter/paginate; `limit` max 50, default 20 |
`read` and `prepare_patch` return the resolved `skillName`. Reuse that name as
`skill_name` in follow-up calls; a metadata `skillKey` can match a different
skill's exact name. Update proposals and revisions preserve the existing skill's
frontmatter name.
Only one prepared patch span may be active per skill. A second
`prepare_patch` is rejected until a `patch` attempt consumes or invalidates the
active authorization.
`inspect` returns proposal metadata, a bounded artifact manifest, and one
complete artifact when it fits the selected model's context budget. It selects
`PROPOSAL.md` by default. Set `artifact_path` to read one support file
separately. When the selected artifact does not fit, the result omits its body,
reports the original size, and points to smaller per-artifact reads or the
unbounded operator [CLI command](/tools/skill-workshop/authoring#cli).
Agents must use `skill_workshop` for generated skill work and must not create or
change skill or proposal files directly during foreground authoring. Automatic
background maintenance uses the rooted file-tool path described in
[Self-learning and approval settings](/tools/skill-workshop/configuration) instead.
The foreground rule is advisory and prompt-enforced. The tool-policy seam does
not enforce it.
<Note>
`skill_workshop` is a built-in agent tool and is included in
`tools.profile: "coding"`. If a stricter policy hides it, add
`skill_workshop` to the active `tools.allow` list, or use
`tools.alsoAllow: ["skill_workshop"]` when the scope uses a profile without an
explicit `tools.allow`. Sandboxed runs do not construct the host-side
Workshop proposal tool. When an authorized personal-library capability is
available, sandbox and cloud runs use its Gateway-backed authoring surface
instead; the library and database are not mounted writable into the worker.
Use a normal host-side session or the CLI for Workshop proposal review.
</Note>

View file

@ -1,212 +0,0 @@
---
summary: "Skill Workshop Gateway methods, on-disk storage layout, migration, and hard limits"
title: "Gateway methods, storage, and limits"
read_when:
- You are calling the skills.proposals.* or skills.workshop.* Gateway methods
- You need the Workshop on-disk layout or its migration behavior
- You are hitting a Workshop size or count limit
---
## Gateway methods
| Method | Scope |
| ---------------------------------- | ---------------- |
| `skills.proposals.list` | `operator.read` |
| `skills.workshop.read` | `operator.read` |
| `skills.proposals.inspect` | `operator.read` |
| `skills.proposals.historyStatus` | `operator.read` |
| `skills.proposals.historyScan` | `operator.admin` |
| `skills.proposals.create` | `operator.admin` |
| `skills.proposals.update` | `operator.admin` |
| `skills.proposals.revise` | `operator.admin` |
| `skills.proposals.requestRevision` | `operator.admin` |
| `skills.proposals.apply` | `operator.admin` |
| `skills.proposals.reject` | `operator.admin` |
| `skills.proposals.quarantine` | `operator.admin` |
| `skills.curator.status` | `operator.read` |
| `skills.curator.pin` | `operator.admin` |
| `skills.curator.unpin` | `operator.admin` |
| `skills.curator.restore` | `operator.admin` |
`skills.proposals.list` includes `installedSkills`, the current Workshop inventory
for the selected agent. Each entry contains `name`, `skillKey`, and `description`.
The separate `proposals` array remains the proposal history and pending queue.
`skills.workshop.read` accepts `name` and optional `agentId`. It returns the
current installed skill's `name`, `skillKey`, `description`, and complete `content`.
An unknown agent or a skill outside that agent's Workshop inventory returns an
error. It never reads a retained proposal as a substitute for a missing skill.
### Workshop inventory and usage
`skills.curator.status` reports live skill usage recorded from trusted
`skill.used` events, retained pre-cron collection review records, and per-workspace
experience review outcomes. Current collection reviews use automation run history.
No skill is archived or expired by age. Every skill reports as `active`.
Clients that advertise the `skill-curator-live-inventory` connection capability
receive `inventory: "live-workshop"`. This view discovers current skills in each
configured agent's Workshop root, including custom agent directories and directly
created skills with no proposal history. Disabled skills and skills filtered from
an agent's prompt remain inventory members. Discovery keeps the existing loader's
file-size, per-source count, and link limits. Invalid skills are excluded; a read
failure is an error, not an empty inventory.
Membership and names come from current files. Usage joins by canonical absolute
file path, not skill name. Renaming metadata at the same path retains usage;
moving a file does not transfer its history. Removed roots, agents, and files do
not return through retained proposals. Applied create proposals supply the
earliest known creation date only. Without that history, `createdAtMs` and
`stateChangedAtMs` are `null`; filesystem dates are not substitutes.
A zero `useCount` or null `lastUsedAtMs` means use is not recorded, not that the
skill was never used. Tracking includes successful known skill reads through
OpenClaw tools registered with the Codex dynamic-tool bridge and explicit
tool-dispatched skill commands. It does not infer native Codex skill activation
or reads made outside the OpenClaw tool boundary. There is no historical backfill.
During upgrades, clients without the capability receive the unchanged legacy
response shape: no marker, numeric creation dates, and only current entries whose
dates are known. Counts describe that returned subset. New clients also accept
older Gateways' unmarked responses. The legacy view has limited coverage and must
not be treated as a complete inventory or used to infer inactivity.
`skills.curator.pin`, `skills.curator.unpin`, and `skills.curator.restore` remain
registered for existing clients, but always return an error explaining that the
weekly collection review manages the skill collection.
### Revision and history methods
`requestRevision` is Gateway-only (no CLI or agent-tool equivalent): it
forwards free-text revision instructions to the owning agent's chat session
instead of replacing `PROPOSAL.md` directly, for UIs that ask the agent to
revise rather than submit literal new content.
`historyStatus` and `historyScan` remain registered for existing clients but
return an error explaining that historical batch scans are retired. They do not
start a run or change skills.
In Workshop, **Learn from past conversations** creates and opens a normal agent
session through `sessions.create`. Its opening message asks the agent to inspect
existing skills, choose useful conversations, and follow the current learning
mode. The chat shows the work and its outcome; there is no separate scan progress
store. See [Self-learning](/tools/self-learning#cost-and-privacy) for model usage
and privacy.
## Storage
```text
<state-dir>/
state/openclaw.sqlite
agents/<agentId>/
agent/workshop-skills/<skill-name>/
SKILL.md
assets/
examples/
references/
scripts/
templates/
skill-workshop/proposals/<proposal-id>/
generations/<generation-id>/
PROPOSAL.md
assets/
examples/
references/
scripts/
templates/
```
Unless overridden, `<state-dir>` is `~/.openclaw`.
- `state/openclaw.sqlite`: canonical proposal records and provenance, the active
generation reference, proposal status, recorded skill usage, collection and
experience review outcomes, and apply rollback metadata.
- Each generation contains one `PROPOSAL.md` and all of that revision's support
files. Revision publication never overwrites the active generation in place.
- Generation files are flushed before publication. After the complete bundle is
renamed into place, OpenClaw syncs the `generations/` parent directory where
the platform supports directory flushing, before committing SQLite state.
Platforms that report directory synchronization as unsupported retain atomic
rename and process-interruption safety, but do not claim power-loss durability
for that directory entry.
- Support files remain beside their generation's `PROPOSAL.md` so operators can
review the proposed skill as a normal directory.
Proposals created by older releases can still reference the earlier root-level
`PROPOSAL.md` layout. The stored record identifies that bundle directly; the
next successful revision moves the proposal onto the generation layout and
retires the previous bundle.
Startup and `openclaw doctor --fix` use the same Workshop migration. It imports
the previous `proposals.json`, `proposal.json`, and `rollback.json` metadata into
SQLite after verifying each proposal, then removes the migrated JSON files.
It moves applied legacy Workshop creates into `workshop-skills`, retargets
eligible pending creates, and marks outside updates stale before normal use.
Pending updates follow their relocated skill in the same database commit.
Ownership-only moves preserve the proposal's existing edit time.
Interrupted moves resume without discarding those pending updates.
If older workspace setup files remain, run `openclaw doctor --fix`.
Startup defers the affected skill moves and backup conversion until Doctor
has imported that workspace state.
The migration infers each legacy proposal's owner from its row, origin metadata,
or a unique workspace owner. Ambiguous ownership, or an owner that is no
longer in the agent roster, stays in place and becomes stale.
Legacy collection backups move under the owner agent's backup root together
with their post-cleanup snapshot. A dropped skill remains restorable when its
saved review and create proposal prove its owner, original path, and backup.
Backups without enough ownership evidence remain history-only; their legacy
files stay in place, and restore reports why it cannot use them. Completed
history archives do not block migration of the remaining backups.
If cleanup stops after publishing a restorable backup, the next migration
verifies the saved manifest and all copied files before removing the old copy.
If no configured agent uses a legacy backup's recorded workspace, Doctor checks
its post-cleanup result hashes against every configured agent's Workshop skills.
Exactly one complete match identifies the owner even when the old workspace is
gone. Empty, partial, changed, or ambiguous matches stay preserved; Doctor names
the agents considered and their verification results. Follow the
[manual backup recovery procedure](/tools/skill-workshop/collection-review#when-an-older-backup-cannot-be-restored-automatically)
without changing the current workspace or rewriting the backup manifest.
After a successful repair, restart the Gateway to clear its startup warning.
Skills that were symlinked into a workspace stay where they are as workspace
skills; the migration marks their proposals stale instead of moving them.
Doctor also checks saved automation command arguments, working directories,
condition scripts, and agent messages for literal references to relocated
Workshop skills. It names each affected automation and field during Doctor
repair and on later checks. Retained apply history must establish the original
skill directory; Doctor does not guess it from the current workspace or skill
name when that history is unavailable.
Review each reported field before updating the automation. Doctor offers a
replacement for a complete argument or working-directory path only when the
mapped target exists inside the relocated skill. For paths embedded in scripts
or messages, it reports the directory relocation separately and leaves the
complete target unresolved for manual review. Missing or ambiguous targets also
stay unresolved. Doctor does not rewrite automation content, change schedules,
or run the automation to check it.
If moving the skills empties a workspace, migration retires obsolete
workspace-survival evidence only when saved pre-move facts prove that the same
directory contained only those skills and every moved file is intact.
Missing or replaced workspaces, ordinary project files, and newer workspace
attestations keep their protection.
If a proposal's draft is missing, Suggestions marks it unavailable. You can
reject it, but cannot apply, evaluate, or revise content that is no longer there.
Run `openclaw doctor --fix` to mark these proposals stale and remove them from
actionable Suggestions. Doctor preserves their metadata and remaining files.
If a proposal has unfinished apply recovery, Reject and Quarantine refuse to
dismiss it. Doctor leaves it pending and asks you to restore the draft before
retrying; it does not discard rollback evidence
or change the installed skill.
## Limits
| Limit | Value |
| ------------------------------- | ---------------------------------------------------------------------------- |
| Description | 160 bytes |
| Proposal body | `skills.workshop.maxSkillBytes` (default 40,000; hard ceiling 200,000 bytes) |
| Autonomous proposal `SKILL.md` | 10,000 characters, or strictly shorter when already over the cap |
| Support files | 64 per proposal |
| Support file size | 256 KiB each, 2 MiB total |
| Pending + quarantined proposals | `skills.workshop.maxPending` per agent (default 50) |

View file

@ -1,74 +0,0 @@
---
summary: "Common Skill Workshop errors and the tool-policy doctor check"
title: "Skill Workshop troubleshooting"
read_when:
- A Workshop proposal fails to create, apply, or appear in the list
- The agent cannot call skill_workshop
---
## Troubleshooting
| Problem | Resolution |
| ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Skill proposal description is too large` | Shorten `description` to 160 bytes or less. |
| `Skill proposal content is too large` | Shorten the proposal body or raise `skills.workshop.maxSkillBytes`. |
| `Target skill changed after proposal creation` | Revise the proposal against the current target, or create a new proposal. |
| `Proposal scan failed` | Inspect scanner findings, then revise or quarantine the proposal. |
| `Support file paths must be under one of...` | Move support files under `assets/`, `examples/`, `references/`, `scripts/`, or `templates/`. |
| Proposal does not show in list | Check the selected agent and `OPENCLAW_STATE_DIR`. |
| Agent cannot call `skill_workshop` | Check the active tool policy and run mode. `coding` includes the tool; restrictive `tools.allow` policies must list it explicitly, and sandboxed runs must use a normal host-side agent session or the CLI. |
### Legacy ownership warnings during an update
Doctor discovers historical workspace setup files from retained Workshop proposals
and collection backups, even when the agent now uses a different workspace. It
imports valid setup history before relocating Workshop records, preserving the
original timestamps and an archived copy without changing workspace configuration.
If an unconfigured historical workspace has missing or unreadable setup state,
Doctor preserves its files and reports a warning. Obsolete proposals become stale;
proposals with unfinished apply recovery and ambiguous collection backups remain
available for manual review. Configured workspace failures and failures after an
import has begun still block repair.
Doctor leaves legacy proposal metadata and collection backups in place when
their workspace has no configured owner or maps to more than one agent. The
warning names the retained path and candidate agents. These ownership warnings
do not stop the other migrations or later Doctor repairs.
Review the retained proposal or backup manifest alongside the configured agent
workspaces. Correct a workspace mapping only when it identifies the actual
owner; do not assign an arbitrary agent or delete the artifacts to clear the
warning. Rerun Doctor after resolving ownership. Outside the historical-workspace
deferrals above, invalid metadata, failed writes, and unfinished recovery remain
migration failures.
A retarget count describes proposals changed during that pass. Any remaining
external targets can belong to different proposals whose migration is blocked.
Review their identifiers, paths, and migration warnings before retrying the same
repair.
Package rollback does not reverse the state-schema migration or relocated skill
files. An older package can therefore be incompatible with the retained state.
See [Schema bumps and older updaters](/reference/database-schemas#schema-bumps-and-older-updaters)
and [Downgrade recovery](/reference/database-schemas#downgrade-recovery).
### Tool-policy diagnostic
In `propose` and `auto` modes, `openclaw doctor` runs the
`core/doctor/skill-workshop-tool-policy` check for each configured agent. Doctor
checks the sandbox construction gate before tool policy: sandboxed runs without
host-granted library-authoring authority do not construct `skill_workshop`, even
when `alsoAllow` lists it. The warning names the effective sandbox setting and
recommends a non-sandboxed session, a human turn with library-authoring authority,
or the `openclaw skills workshop` CLI. For `non-main` mode, this restriction applies
to non-main sessions. Library authority is granted by the host for an authenticated
human turn; it is not an allowlist entry or a permission autonomous reviews can
grant themselves. See [Personal library authoring](/tools/skill-workshop/personal-library).
When the tool can be constructed but policy hides it, the warning names the first
excluding config layer and the exact `allow` or `alsoAllow` change to make. A run
whose explicit allowlist leaves no callable tools also names the sandbox gate
when it excludes the requested Workshop tool. Older runbooks may still use
`openclaw plugins inspect skill-workshop`; that command now explains that Skill
Workshop is built in and prints the same policy hint when applicable.

View file

@ -5,7 +5,7 @@ summary: "Full reference for the skills.* config schema, agent allowlists, works
read_when:
- Configuring skill loading, install, or gating behavior
- Setting per-agent skill visibility
- Adjusting Skill Workshop limits or approval policy
- Adjusting Skill Workshop learning mode or size limits
---
Most skills configuration lives under `skills` in
@ -28,8 +28,6 @@ Most skills configuration lives under `skills` in
},
workshop: {
autonomous: { mode: "auto" },
approvalPolicy: "auto",
maxPending: 50,
maxSkillBytes: 40000,
},
entries: {
@ -365,36 +363,31 @@ different visible skill set per agent.
## Workshop (`skills.workshop`)
<ParamField path="skills.workshop.autonomous.mode" type='"off" | "propose" | "auto"' default='"auto"'>
`off` disables autonomous capture while keeping the durable-instruction
suggestion nudge. `propose` creates pending proposals from corrections and
substantial completed work. `auto` uses normal agent tools for direct per-turn
and weekly Workshop maintenance, without proposal scanning or automatic rollback
snapshots. Immediate foreground repairs still use scanner-gated proposal apply.
User-prompted skill creation,
`/learn`, and manual learning sessions continue to work in every mode.
<ParamField path="skills.workshop.autonomous.mode" type='"off" | "auto"' default='"auto"'>
`auto` lets agents save and update Workshop skills: a background review runs
after substantial work, and learned skills unused for 30 days are archived.
Review changes are announced in the conversation, and every change can be
undone. `off` disables the background review and unused-skill cleanup.
User-prompted skill creation, `/learn`, and manual learning sessions work in
both modes.
</ParamField>
See [Self-learning](/tools/self-learning) for eligibility, privacy, cost,
proposal-only permissions, and troubleshooting.
<ParamField path="skills.workshop.approvalPolicy" type='"pending" | "auto"' default='"auto"'>
`auto` allows agent-initiated apply, reject, or quarantine without an
additional approval prompt. `pending` requires operator approval.
</ParamField>
<ParamField path="skills.workshop.maxPending" type="number" default="50">
Maximum pending and quarantined proposals retained per agent (allowed
range: 1-200).
</ParamField>
and troubleshooting.
<ParamField path="skills.workshop.maxSkillBytes" type="number" default="40000">
Maximum proposal body size in bytes (allowed range: 1024-200000). Proposal
descriptions are hard-capped at 160 bytes separately, because they appear
in discovery and listing output.
Maximum `SKILL.md` size in bytes for Workshop skills (allowed range:
1024-200000). Skill descriptions are capped at 1024 bytes separately; keep
them near 160 bytes because they appear in discovery and listing output.
</ParamField>
See [Skill Workshop](/tools/skill-workshop) for the proposal lifecycle, CLI
`openclaw doctor --fix` migrates configs from the removed proposal flow: it
changes `autonomous.mode: "propose"` to `"off"` and deletes
`skills.workshop.approvalPolicy` and `skills.workshop.maxPending`. Run
`openclaw config set skills.workshop.autonomous.mode auto` to turn automatic
learning back on.
See [Skill Workshop](/tools/skill-workshop) for the skill lifecycle, CLI
commands, agent tool parameters, and Gateway methods this config controls.
## Symlinked skill roots
@ -478,10 +471,10 @@ for when changes become visible.
Authoring custom workspace skills.
</Card>
<Card title="Skill Workshop" href="/tools/skill-workshop" icon="flask">
Proposal queue for agent-drafted skills.
Agent-learned skills, change history, and undo.
</Card>
<Card title="Self-learning" href="/tools/self-learning" icon="brain">
Conservative, opt-in proposals from completed work.
Automatic, undoable skill learning from completed work.
</Card>
<Card title="Slash commands" href="/tools/slash-commands" icon="terminal">
Native slash-command catalog and chat directives.

View file

@ -19,7 +19,7 @@ binary presence.
Build and test a custom skill from scratch.
</Card>
<Card title="Skill Workshop" href="/tools/skill-workshop" icon="flask">
Review and approve agent-drafted skill proposals.
Skills your agent learns on its own, with change history and undo.
</Card>
<Card title="Skills config" href="/tools/skills-config" icon="gear">
Full `skills.*` config schema and agent allowlists.
@ -314,22 +314,22 @@ skill from model-initiated selection.
## Skill Workshop
[Skill Workshop](/tools/skill-workshop) is a proposal queue between the agent
and its `<state-dir>/agents/<agentId>/agent/workshop-skills` directory. When the agent spots
reusable work, it drafts a proposal instead of writing directly to `SKILL.md`.
The scheduled weekly collection review is the scoped exception: its normal
isolated turn may edit `SKILL.md` files directly inside the Workshop directory.
Operators edit skills outside that directory through their owning tools or files.
[Skill Workshop](/tools/skill-workshop) lets an agent save and update its own
skills in `<state-dir>/agents/<agentId>/agent/workshop-skills`. Every change
applies immediately, is announced, and saves the previous version so it can be
undone. Learned skills are always visible to their agent; archive one to hide
it. Operators edit skills outside that directory through their owning tools or
files.
```bash
openclaw skills workshop list
openclaw skills workshop inspect <proposal-id>
openclaw skills workshop evaluate <proposal-id>
openclaw skills workshop apply <proposal-id>
openclaw skills workshop changes
openclaw skills workshop archive <name>
openclaw skills workshop restore <name>
```
See [Skill Workshop](/tools/skill-workshop) for the full lifecycle, CLI
reference, and configuration.
See [Skill Workshop](/tools/skill-workshop) for how agents learn skills and
[`openclaw skills workshop`](/cli/skills#skill-workshop) for the CLI reference.
## Installing from ClawHub
@ -978,7 +978,7 @@ read every admitted skill. Native harnesses retain their own prompt policy.
Step-by-step guide to authoring a custom skill.
</Card>
<Card title="Skill Workshop" href="/tools/skill-workshop" icon="flask">
Proposal queue for agent-drafted skills.
Skills your agent saves and updates on its own, with undo.
</Card>
<Card title="Skills config" href="/tools/skills-config" icon="gear">
Full `skills.*` config schema and agent allowlists.

View file

@ -281,8 +281,8 @@ Copy controls. Reading a bundle requires `operator.read`.
The **Skills** tab keeps the skill status report, enable/disable toggles, API
key entry, and inline ClawHub skill search, scoped to the selected agent. The
**Skill workshop** tab shows installed skills and pending
[skill proposals](/tools/skill-workshop). **Learn from past conversations** opens
**Skill workshop** tab shows [learned skills](/tools/skill-workshop) with their
use counts and recent changes with **Undo**. **Learn from past conversations** opens
a normal session with the selected agent's configured model and permitted tools.
The agent chooses which history and skills to inspect, following the current
Workshop mode. Chat shows progress, results, and normal stop and follow-up controls.

View file

@ -177,7 +177,6 @@ export function buildAnthropicCliBackend(
nativeToolMode: "selectable",
hostOwnedTools: ["exec", "process"],
toolAvailabilityEnforcement: "execution-args",
isolatesInstructionsWithExactTools: true,
projectNativeToolAuthority: projectClaudeNativeToolAuthority,
sideQuestionToolMode: "disabled",
ownsNativeCompaction: true,

View file

@ -2,9 +2,8 @@ import { describe, expect, it } from "vitest";
import { buildAnthropicCliBackend } from "./cli-backend.js";
describe("Claude CLI instruction isolation", () => {
it.each([false, true])("isolates declared exact-tool execution (resume=%s)", (useResume) => {
it.each([false, true])("isolates exact-tool execution (resume=%s)", (useResume) => {
const backend = buildAnthropicCliBackend();
expect(backend.isolatesInstructionsWithExactTools).toBe(true);
expect(
backend.resolveExecutionArgs?.({
workspaceDir: "/tmp",

View file

@ -1100,24 +1100,39 @@ describe("runCodexAppServerSideQuestion", () => {
},
);
it("disables hosted search when side-question sender policy removes managed web_search", async () => {
createOpenClawCodingToolsMock.mockReturnValue([]);
it.each([
{ senderId: "restricted-sender", webSearchMode: "disabled" },
{ senderId: "allowed-sender", webSearchMode: "cached" },
])(
"applies side-question search policy for $senderId without managed search",
async (testCase) => {
// Missing managed credentials are not a denial; hosted search uses the policy owner.
createOpenClawCodingToolsMock.mockReturnValue([]);
const { forkConfig } = await runSideQuestionWithManagedWebSearchCall(
sideParams({
senderId: "restricted-sender",
cfg: {
tools: { toolsBySender: { "id:restricted-sender": { deny: ["web_search"] } } },
},
}),
{ preserveToolFactory: true },
);
const { forkConfig, toolResponse } = await runSideQuestionWithManagedWebSearchCall(
sideParams({
senderId: testCase.senderId,
cfg: {
tools: { toolsBySender: { "id:restricted-sender": { deny: ["web_search"] } } },
},
}),
{ preserveToolFactory: true },
);
expect(forkConfig).toMatchObject({
"features.standalone_web_search": false,
web_search: "disabled",
});
});
expect(createOpenClawCodingToolsMock).toHaveBeenCalledWith(
expect.objectContaining({ senderId: testCase.senderId }),
);
expect(forkConfig).toMatchObject({
"features.standalone_web_search": false,
web_search: testCase.webSearchMode,
});
expect(toolResponse).toEqual({
success: false,
contentItems: [{ type: "inputText", text: "Unknown OpenClaw tool: web_search" }],
});
expect(toolExecuteMock).not.toHaveBeenCalled();
},
);
it("rejects side questions before forking when the tool allowlist excludes native tools", async () => {
await expect(

View file

@ -59,8 +59,6 @@ const schemaMigrationDescriptions = {
"creator-namespace-v14": "cron creators -> explicit principal namespaces",
"conversation-binding-targets-v15":
"conversation bindings -> exact target keys without agent/session projections",
"skill-workshop-directory-ownership-v16":
"Skill Workshop proposals -> per-agent Workshop directory ownership",
"prepared-worker-ownership-v17":
"prepared workers -> one-use capacity and fixed workspace ownership",
"github-publication-requester-authority-v18":

View file

@ -81,7 +81,6 @@ export const GATEWAY_CLIENT_CAPS = {
MODEL_SELECTION_POLICY: "model-selection-policy",
RUN_TOOL_BINDINGS: "run-tool-bindings",
SESSION_SCOPED_EVENTS: "session-scoped-events",
SKILL_CURATOR_LIVE_INVENTORY: "skill-curator-live-inventory",
PLUGIN_APPROVALS: "plugin-approvals",
TASK_SUGGESTIONS: "task-suggestions",
TERMINAL_OFFSET_SEQ: "terminal-offset-seq",

View file

@ -6,12 +6,10 @@ export * from "./system-agent-error-details.js";
export {
ErrorCodes,
GatewayErrorDetailCodes,
buildSkillProposalRevisionChangedErrorDetails,
isMcpAppViewExpiredError,
readCronJobNotFoundError,
readMissingScopeError,
readMissingScopeErrorDetails,
readSkillProposalRevisionChangedError,
} from "./gateway-error-details.js";
export type {
CronJobNotFoundErrorDetails,
@ -19,7 +17,6 @@ export type {
McpAppViewExpiredErrorDetails,
OutboundDeliveryQueuedErrorDetails,
MissingScopeErrorDetails,
SkillProposalRevisionChangedErrorDetails,
UserPrefsLimitExceededErrorDetails,
ProjectCloneErrorDetails,
ProjectCloneFailureCause,
@ -34,7 +31,6 @@ export {
OutboundDeliveryQueuedErrorDetailsSchema,
UserPrefsLimitExceededErrorDetailsSchema,
ProjectCloneErrorDetailsSchema,
SkillProposalRevisionChangedErrorDetailsSchema,
WizardNotFoundErrorDetailsSchema,
SetupAdmissionBusyErrorDetailsSchema,
SessionWorkspaceRecoveryRequiredErrorDetailsSchema,

View file

@ -34,7 +34,6 @@ export const GatewayErrorDetailCodes = {
OUTBOUND_DELIVERY_QUEUED: "OUTBOUND_DELIVERY_QUEUED",
USER_PREFS_LIMIT_EXCEEDED: "USER_PREFS_LIMIT_EXCEEDED",
SESSION_COMPANION_BUSY: "SESSION_COMPANION_BUSY",
SKILL_PROPOSAL_REVISION_CHANGED: "SKILL_PROPOSAL_REVISION_CHANGED",
PROJECT_CLONE_FAILED: "PROJECT_CLONE_FAILED",
UNKNOWN_AGENT_ID: "UNKNOWN_AGENT_ID",
WIZARD_NOT_FOUND: "WIZARD_NOT_FOUND",
@ -108,13 +107,6 @@ export type ProjectCloneErrorDetails = {
cause: ProjectCloneFailureCause;
};
/** Optimistic-concurrency mismatch for an operator-reviewed Skill Workshop draft. */
export type SkillProposalRevisionChangedErrorDetails = {
code: typeof GatewayErrorDetailCodes.SKILL_PROPOSAL_REVISION_CHANGED;
expectedRevisionHash: string;
currentRevisionHash: string;
};
/** Exact retained workspace owner that must be recovered or explicitly abandoned. */
export type SessionWorkspaceRecoveryRequiredErrorDetails = {
code: typeof GatewayErrorDetailCodes.SESSION_WORKSPACE_RECOVERY_REQUIRED;
@ -137,7 +129,6 @@ export type GatewayErrorDetails =
| McpAppViewExpiredErrorDetails
| OutboundDeliveryQueuedErrorDetails
| UserPrefsLimitExceededErrorDetails
| SkillProposalRevisionChangedErrorDetails
| ProjectCloneErrorDetails
| UnknownAgentIdErrorDetails
| WizardNotFoundErrorDetails
@ -148,7 +139,6 @@ export type GatewayErrorDetails =
| { code: typeof GatewayErrorDetailCodes.TASK_HISTORY_PREVIEW_CAPACITY };
const LEGACY_MISSING_SCOPE_PATTERN = /\bmissing scope:\s*([a-z0-9._-]+)/i;
const SHA256_PATTERN = /^[a-fA-F0-9]{64}$/;
export function readGitHubPublicationSelectionRejectedError(
error: unknown,
@ -175,40 +165,6 @@ export function readCronJobNotFoundError(error: unknown): CronJobNotFoundErrorDe
return jobId ? { code: GatewayErrorDetailCodes.CRON_JOB_NOT_FOUND, jobId } : null;
}
/** Builds the canonical stale-draft details shared by Skill Workshop RPCs. */
export function buildSkillProposalRevisionChangedErrorDetails(params: {
expectedRevisionHash: string;
currentRevisionHash: string;
}): SkillProposalRevisionChangedErrorDetails {
return {
code: GatewayErrorDetailCodes.SKILL_PROPOSAL_REVISION_CHANGED,
expectedRevisionHash: params.expectedRevisionHash,
currentRevisionHash: params.currentRevisionHash,
};
}
/** Reads a stale Skill Workshop decision without parsing operator-facing prose. */
export function readSkillProposalRevisionChangedError(
error: unknown,
): SkillProposalRevisionChangedErrorDetails | null {
const record = asProtocolRecord(error);
const details = asProtocolRecord(record?.details);
if (details?.code !== GatewayErrorDetailCodes.SKILL_PROPOSAL_REVISION_CHANGED) {
return null;
}
const expectedRevisionHash =
typeof details.expectedRevisionHash === "string" ? details.expectedRevisionHash : "";
const currentRevisionHash =
typeof details.currentRevisionHash === "string" ? details.currentRevisionHash : "";
if (!SHA256_PATTERN.test(expectedRevisionHash) || !SHA256_PATTERN.test(currentRevisionHash)) {
return null;
}
return buildSkillProposalRevisionChangedErrorDetails({
expectedRevisionHash,
currentRevisionHash,
});
}
/** Reads validated missing-scope details from an untrusted protocol payload. */
export function readMissingScopeErrorDetails(details: unknown): MissingScopeErrorDetails | null {
const record = asProtocolRecord(details);

View file

@ -12,7 +12,6 @@ import {
validateWakeParams,
type ValidationError,
} from "./index.js";
import { ProtocolSchemas } from "./schema/protocol-schemas.js";
const makeError = (overrides: Partial<ValidationError>): ValidationError => ({
keyword: "type",
@ -41,21 +40,6 @@ const expectRejected = (validate: ProtocolValidator, values: readonly unknown[])
expectValidationCases(validate, false, values);
describe("lazy protocol validators", () => {
it("registers Skill Workshop evaluation and lifecycle replay schemas", () => {
expect(ProtocolSchemas.SkillsProposalEvaluateParams).toBe(
protocol.SkillsProposalEvaluateParamsSchema,
);
expect(ProtocolSchemas.SkillsProposalEvaluateResult).toBe(
protocol.SkillsProposalEvaluateResultSchema,
);
expect(ProtocolSchemas.SkillsProposalEventsListParams).toBe(
protocol.SkillsProposalEventsListParamsSchema,
);
expect(ProtocolSchemas.SkillsProposalEventsListResult).toBe(
protocol.SkillsProposalEventsListResultSchema,
);
});
it("validates through exported lazy validators", () => {
expectAccepted(validateCommandsListParams, [{}, { includeArgs: true }]);
expectRejected(validateCommandsListParams, [{ includeArgs: "yes" }]);
@ -94,22 +78,27 @@ describe("lazy protocol validators", () => {
expect(validateConnectParams.errors).toBeNull();
});
it("rejects caller-provided hidden prompts in Skill Workshop revisions", () => {
const request = {
proposalId: "proposal-1",
expectedRevisionHash: "a".repeat(64),
targetAgentId: "writer",
instructions: "Revise the support files",
sessionKey: "agent:main:session:skill-workshop",
idempotencyKey: "revision-run-1",
};
expect(protocol.validateSkillsProposalRequestRevisionParams(request)).toBe(true);
expect(
protocol.validateSkillsProposalRequestRevisionParams({
...request,
hiddenPrompt: "do not accept caller-provided hidden prompts",
}),
).toBe(false);
it("validates Skill Workshop request params", () => {
expectAccepted(protocol.validateSkillsWorkshopChangesParams, [
{},
{ agentId: "main", limit: 500, beforeMs: 1_700_000_000_000 },
]);
expectRejected(protocol.validateSkillsWorkshopChangesParams, [{ limit: 0 }, { limit: 501 }]);
expectAccepted(protocol.validateSkillsWorkshopReadParams, [
{ name: "deploy-notes", filePath: "references/api.md", versionId: "v1" },
]);
expectRejected(protocol.validateSkillsWorkshopReadParams, [{}, { name: "" }]);
expectAccepted(protocol.validateSkillsWorkshopArchiveParams, [
{ name: "deploy-notes", reason: "superseded" },
]);
expectRejected(protocol.validateSkillsWorkshopArchiveParams, [
{ name: "deploy-notes", reason: "" },
{ name: "deploy-notes", absorbedInto: "other" },
]);
expectAccepted(protocol.validateSkillsWorkshopRestoreParams, [{ name: "deploy-notes" }]);
expectRejected(protocol.validateSkillsWorkshopRestoreParams, [
{ name: "deploy-notes", expectedRevisionHash: "a".repeat(64) },
]);
});
it("can still compile every exported protocol validator", () => {

View file

@ -18,7 +18,6 @@ export * from "./schema/worker-inference.js";
export * from "./schema/worker-gateway-tool.js";
export * from "./schema/worker-computer.js";
export * from "./schema/computer.js";
export * from "./schema/skill-history.js";
export * from "./schema/skill-library.js";
export * from "./schema/plugin-credentials.js";
export * from "./schema/web-search.js";

View file

@ -502,35 +502,22 @@ export {
ToolsEffectiveParamsSchema,
ToolsInvokeParamsSchema,
SkillsInstallParamsSchema,
SkillsCuratorActionParamsSchema,
SkillsCuratorActionResultSchema,
SkillsCuratorStatusParamsSchema,
SkillsCuratorStatusResultSchema,
SkillsSearchParamsSchema,
SkillsSearchResultSchema,
SkillsDetailParamsSchema,
SkillsDetailResultSchema,
SkillsProposalsListParamsSchema,
SkillsProposalsListResultSchema,
SkillWorkshopChangeSchema,
SkillWorkshopSkillSummarySchema,
SkillWorkshopArchivedSkillSchema,
SkillsWorkshopListParamsSchema,
SkillsWorkshopListResultSchema,
SkillsWorkshopChangesParamsSchema,
SkillsWorkshopChangesResultSchema,
SkillsWorkshopReadParamsSchema,
SkillsWorkshopReadResultSchema,
SkillsProposalInspectParamsSchema,
SkillsProposalInspectResultSchema,
SkillsProposalCreateParamsSchema,
SkillsProposalUpdateParamsSchema,
SkillsProposalReviseParamsSchema,
SkillsProposalRequestRevisionParamsSchema,
SkillsProposalRequestRevisionResultSchema,
SkillsProposalDecisionParamsSchema,
SkillsProposalActionParamsSchema,
SkillProposalEvaluationSchema,
SkillsProposalEvaluateParamsSchema,
SkillsProposalEvaluateResultSchema,
SkillProposalLifecycleEventSchema,
SkillsProposalEventsListParamsSchema,
SkillsProposalEventsListResultSchema,
SkillsProposalApplyResultSchema,
SkillsProposalRecordResultSchema,
SkillsWorkshopArchiveParamsSchema,
SkillsWorkshopRestoreParamsSchema,
SkillsWorkshopChangeResultSchema,
SkillsSecurityVerdictsParamsSchema,
SkillsSecurityVerdictsResultSchema,
SkillsSkillCardParamsSchema,

View file

@ -51,7 +51,6 @@ export * from "./schema/sessions-sharing.js";
export * from "./schema/sessions-suggestions.js";
export * from "./schema/sessions-reactions.js";
export * from "./schema/sessions-catalog.js";
export * from "./schema/skill-history.js";
export * from "./schema/snapshot.js";
export * from "./schema/storage.js";
export * from "./schema/backup.js";

View file

@ -1,35 +0,0 @@
import { Value } from "typebox/value";
import { describe, expect, it } from "vitest";
import {
SkillsProposalActionParamsSchema,
SkillsProposalDecisionParamsSchema,
SkillsProposalRequestRevisionParamsSchema,
} from "./agents-models-skills.js";
describe("Skill Workshop revision-bound request schemas", () => {
it("requires exact revision hashes for operator decisions and natural revisions", () => {
const decision = { proposalId: "proposal-1", expectedRevisionHash: "a".repeat(64) };
expect(Value.Check(SkillsProposalDecisionParamsSchema, decision)).toBe(true);
expect(Value.Check(SkillsProposalDecisionParamsSchema, { proposalId: "proposal-1" })).toBe(
false,
);
expect(Value.Check(SkillsProposalActionParamsSchema, { proposalId: "proposal-1" })).toBe(true);
expect(
Value.Check(SkillsProposalRequestRevisionParamsSchema, {
...decision,
instructions: "Revise the draft",
sessionKey: "agent:main:revision",
idempotencyKey: "revision-1",
}),
).toBe(true);
expect(
Value.Check(SkillsProposalRequestRevisionParamsSchema, {
proposalId: "proposal-1",
instructions: "Revise the draft",
sessionKey: "agent:main:revision",
idempotencyKey: "revision-1",
}),
).toBe(false);
});
});

View file

@ -12,18 +12,7 @@ import {
ModelsListResultSchema,
ModelsProbeParamsSchema,
ModelsProbeResultSchema,
SkillProposalEvaluationSchema,
SkillProposalLifecycleEventSchema,
SkillsCuratorStatusResultSchema,
SkillsCuratorLiveStatusResultSchema,
SkillsCuratorActionResultSchema,
SkillsDetailResultSchema,
SkillsProposalEvaluateParamsSchema,
SkillsProposalEvaluateResultSchema,
SkillsProposalEventsListParamsSchema,
SkillsProposalEventsListResultSchema,
SkillsProposalInspectResultSchema,
SkillsProposalRequestRevisionResultSchema,
ToolsInvokeParamsSchema,
} from "./agents-models-skills.js";
import { ModelsListParamsSchema } from "./model-catalog.js";
@ -42,52 +31,6 @@ const expectAccepted = (schema: ProtocolSchema, ...values: readonly unknown[]) =
const expectRejected = (schema: ProtocolSchema, ...values: readonly unknown[]) =>
expectSchemaCases(schema, false, values);
const cleanProposalScan = {
state: "clean",
scannedAt: "2026-05-30T00:00:00.000Z",
critical: 0,
warn: 0,
info: 0,
findings: [],
};
const proposalTarget = (overrides: Record<string, unknown> = {}) => ({
skillName: "weather-helper",
skillDir: "/tmp/workspace/skills/weather-helper",
skillFile: "/tmp/workspace/skills/weather-helper/SKILL.md",
skillKey: "weather-helper",
...overrides,
});
const proposalRecord = (overrides: Record<string, unknown> = {}) => ({
id: "proposal-1",
kind: "create",
status: "pending",
title: "weather-helper",
description: "Improve weather checks",
schema: "openclaw.skill-workshop.proposal.v1",
createdAt: "2026-05-30T00:00:00.000Z",
updatedAt: "2026-05-30T00:00:00.000Z",
createdBy: "gateway",
proposedVersion: "v2",
draftFile: "PROPOSAL.md",
draftHash: "b".repeat(64),
target: proposalTarget(),
scan: cleanProposalScan,
...overrides,
});
const proposalEvaluation = (overrides: Record<string, unknown> = {}) => ({
id: "evaluation-1",
proposedVersion: "v2",
revisionHash: "b".repeat(64),
trigger: "apply",
startedAt: "2026-05-30T00:01:00.000Z",
completedAt: "2026-05-30T00:01:01.000Z",
outcomes: [],
...overrides,
});
describe("AgentsDeleteResultSchema", () => {
it("accepts per-path cleanup outcomes", () => {
expectAccepted(AgentsDeleteResultSchema, {
@ -113,7 +56,7 @@ describe("AgentsDeleteResultSchema", () => {
});
/**
* Schema regression tests for agent metadata, skill proposals, and effective
* Schema regression tests for agent metadata, skills, and effective
* tool catalogs. These payloads are UI-facing but also consumed by runtime
* guards, so the fixtures exercise strictness at the public gateway boundary.
*/
@ -570,307 +513,6 @@ describe("ToolsInvokeParamsSchema", () => {
});
});
describe("SkillsProposalInspectResultSchema", () => {
it("accepts support metadata and the latest bounded evaluation", () => {
const result = {
record: proposalRecord({
kind: "update",
createdBy: "skill-workshop",
proposedVersion: "v1",
target: proposalTarget({
currentContentHash: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
}),
draftHash: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
evaluation: proposalEvaluation({
proposedVersion: "v1",
revisionHash: "a".repeat(64),
trigger: "manual",
correlationId: "correlation-1",
outcomes: [
{
pluginId: "quality-plugin",
pluginVersion: "1.2.3",
evaluatorId: "quality",
status: "completed",
result: {
summary: "Ready to apply.",
findings: [],
metrics: { score: 0.98, deterministic: true, profile: "strict" },
evaluatorVersion: "rules-v2",
mode: "static",
decision: "pass",
decisionReason: "No blocking findings.",
},
},
],
}),
supportFiles: [
{
path: "references/weather.md",
sizeBytes: 42,
hash: "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210",
targetExisted: true,
targetContentHash: "123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0",
},
],
}),
revisionHash: "a".repeat(64),
content: "# Weather Helper\n",
supportFiles: [
{
path: "references/weather.md",
content: "Use current weather before recommendations.\n",
},
],
};
expectAccepted(SkillsProposalInspectResultSchema, result, {
record: result.record,
content: result.content,
});
});
});
describe("SkillsCuratorStatusResultSchema", () => {
const entry = {
skillFile: "/workshop/direct/SKILL.md",
skillKey: "direct",
skillName: "direct",
state: "active",
pinned: false,
createdAtMs: 100,
stateChangedAtMs: 100,
lastUsedAtMs: null,
useCount: 0,
archivedReason: null,
};
const legacy = {
lastAttemptAtMs: null,
lastSuccessAtMs: null,
lastError: null,
counts: { active: 1, stale: 0, archived: 0 },
skills: [entry],
overlaps: [],
};
it("keeps legacy numeric dates closed while live inventory requires its marker", () => {
const unknownEntry = { ...entry, createdAtMs: null, stateChangedAtMs: null };
const full = { ...legacy, inventory: "live-workshop", skills: [unknownEntry] };
expectAccepted(SkillsCuratorStatusResultSchema, legacy);
expectAccepted(SkillsCuratorActionResultSchema, entry);
expectRejected(
SkillsCuratorStatusResultSchema,
full,
{ ...legacy, skills: [unknownEntry] },
{ ...legacy, inventory: "live-workshop" },
);
expectRejected(SkillsCuratorActionResultSchema, unknownEntry);
expectAccepted(SkillsCuratorLiveStatusResultSchema, full, {
...legacy,
inventory: "live-workshop",
});
expectRejected(
SkillsCuratorLiveStatusResultSchema,
legacy,
{ ...full, inventory: "unknown" },
{ ...full, extra: true },
{ ...full, skills: [{ ...unknownEntry, extra: true }] },
);
});
it("accepts typed collection and experience outcomes while rejecting invalid review records", () => {
const legacyResult = {
...legacy,
lastAttemptAtMs: 100,
lastSuccessAtMs: 101,
skills: [],
};
const result = {
...legacyResult,
collectionReview: {
workspace: { attemptedAtMs: 100, succeededAtMs: 101 },
},
experienceReview: {
workspace: {
attemptedAtMs: 102,
outcome: "proposed",
proposalId: "proposal-1",
usage: { inputTokens: 40, cachedInputTokens: 20, outputTokens: 10 },
},
},
};
expectAccepted(SkillsCuratorStatusResultSchema, result, legacyResult);
expectRejected(
SkillsCuratorStatusResultSchema,
{
...result,
collectionReview: { workspace: { attemptedAtMs: 100, unexpected: true } },
},
{
...result,
experienceReview: { workspace: { attemptedAtMs: 102, outcome: "archived" } },
},
);
});
});
describe("SkillProposalEvaluationSchema", () => {
const completedOutcome = {
pluginId: "quality-plugin",
evaluatorId: "quality",
status: "completed",
result: {
findings: [
{
ruleId: "skill.structure",
severity: "warn",
message: "Add a troubleshooting section.",
file: "SKILL.md",
line: 12,
},
],
decision: "revise",
},
};
const evaluation = proposalEvaluation({
targetTreeSha256: "c".repeat(64),
outcomes: [completedOutcome],
});
it("accepts bounded evaluator outcomes", () => {
expectAccepted(SkillProposalEvaluationSchema, evaluation);
});
it("accepts the service result wrapper", () => {
const record = proposalRecord({
evaluation,
});
expectAccepted(SkillsProposalEvaluateResultSchema, { record, evaluation });
});
it("rejects non-primitive metrics and unknown decisions", () => {
expectRejected(SkillProposalEvaluationSchema, {
...evaluation,
outcomes: [
{
...completedOutcome,
result: {
findings: [],
metrics: { nested: { score: 1 } },
decision: "approve",
},
},
],
});
});
it.each(["", "x".repeat(129)])("rejects invalid metric key %j", (key) => {
expectRejected(SkillProposalEvaluationSchema, {
...evaluation,
outcomes: [
{
...completedOutcome,
result: { findings: [], metrics: { [key]: true } },
},
],
});
});
it("enforces status-specific result and error fields", () => {
expectRejected(
SkillProposalEvaluationSchema,
{
...evaluation,
outcomes: [{ pluginId: "quality-plugin", evaluatorId: "quality", status: "completed" }],
},
{
...evaluation,
outcomes: [{ pluginId: "quality-plugin", evaluatorId: "quality", status: "error" }],
},
);
});
});
describe("skill proposal evaluation and event replay params", () => {
it("validates optimistic evaluation and bounded event cursors", () => {
expectAccepted(SkillsProposalEvaluateParamsSchema, {
agentId: "main",
proposalId: "proposal-1",
expectedRevisionHash: "c".repeat(64),
correlationId: "correlation-1",
});
expectAccepted(SkillsProposalEventsListParamsSchema, {
proposalId: "proposal-1",
afterSequence: 10,
limit: 200,
});
expectRejected(SkillsProposalEventsListParamsSchema, { limit: 201 });
});
it("accepts sequence-ordered lifecycle replay pages", () => {
const event = {
sequence: 11,
eventId: "event-11",
proposalId: "proposal-1",
proposedVersion: "v2",
revisionHash: "d".repeat(64),
type: "evaluation_completed",
occurredAt: "2026-05-30T00:01:01.000Z",
actor: { type: "plugin", id: "quality-plugin" },
correlationId: "correlation-1",
payload: { trigger: "manual", outcomeCount: 1, blocking: false, note: null },
evaluation: proposalEvaluation({
id: "evaluation-11",
revisionHash: "d".repeat(64),
trigger: "manual",
}),
};
expectAccepted(SkillProposalLifecycleEventSchema, event);
expectAccepted(SkillsProposalEventsListResultSchema, {
events: [event],
nextSequence: 11,
});
expectRejected(
SkillProposalLifecycleEventSchema,
{
...event,
actor: { type: "operator" },
},
{
...event,
payload: { note: "x".repeat(4_001) },
},
);
for (const key of ["", "x".repeat(81)]) {
expectRejected(SkillProposalLifecycleEventSchema, {
...event,
payload: { [key]: true },
});
}
});
});
describe("SkillsProposalRequestRevisionResultSchema", () => {
it.each(["started", "in_flight", "ok", "timeout", "error"])(
"accepts forwarded chat.send ack status %s",
(status) => {
expectAccepted(SkillsProposalRequestRevisionResultSchema, {
runId: "run-revision",
status,
});
},
);
it("rejects unknown forwarded chat.send ack statuses", () => {
expectRejected(SkillsProposalRequestRevisionResultSchema, {
runId: "run-revision",
status: "queued",
});
});
});
describe("SkillsDetailResultSchema", () => {
it("accepts official ClawHub skill publisher metadata", () => {
const result = {

View file

@ -416,402 +416,117 @@ export const SkillsSkillCardResultSchema = closedObject({
content: Type.String(),
});
const SkillProposalStatusSchema = Type.Union([
Type.Literal("pending"),
Type.Literal("applied"),
Type.Literal("rejected"),
Type.Literal("quarantined"),
Type.Literal("stale"),
const SkillWorkshopChangeActionSchema = Type.Union([
Type.Literal("create"),
Type.Literal("patch"),
Type.Literal("write_file"),
Type.Literal("remove_file"),
Type.Literal("archive"),
Type.Literal("restore"),
]);
/** Skill proposal operation type: new skill or update to an existing skill. */
const SkillProposalKindSchema = Type.Union([Type.Literal("create"), Type.Literal("update")]);
/** Scan state for proposed skill content before it can be applied. */
const SkillProposalScanStateSchema = Type.Union([
Type.Literal("pending"),
Type.Literal("clean"),
Type.Literal("failed"),
Type.Literal("quarantined"),
const SkillWorkshopActorSchema = Type.Union([
Type.Literal("agent"),
Type.Literal("review"),
Type.Literal("curator"),
Type.Literal("user"),
]);
/** Source that created the skill proposal record. */
const SkillProposalSourceSchema = Type.Union([
Type.Literal("skill-workshop"),
Type.Literal("cli"),
Type.Literal("gateway"),
]);
const SkillProposalContentString = Type.String({ minLength: 1, maxLength: 1_048_576 });
/** Support file payload accepted from proposal create/revise requests. */
const SkillProposalSupportFileInputSchema = closedObject({
path: NonEmptyString,
content: Type.String({ maxLength: 262_144 }),
});
/** Stored support file metadata, including target conflict hashes for updates. */
const SkillProposalSupportFileSchema = closedObject({
path: NonEmptyString,
sizeBytes: Type.Integer({ minimum: 0, maximum: 262_144 }),
hash: Sha256String,
targetExisted: Type.Optional(Type.Boolean()),
targetContentHash: Type.Optional(Sha256String),
});
const TimestampMsSchema = Type.Number({ minimum: 0 });
/** One static-scan finding against proposed skill content. */
const SkillProposalFindingSchema = closedObject({
ruleId: NonEmptyString,
severity: Type.Union([Type.Literal("info"), Type.Literal("warn"), Type.Literal("critical")]),
file: NonEmptyString,
line: Type.Integer({ minimum: 1 }),
message: NonEmptyString,
evidence: Type.String(),
});
/** Aggregated scan report attached to a proposal record. */
const SkillProposalScanSchema = closedObject({
state: SkillProposalScanStateSchema,
scannedAt: NonEmptyString,
critical: Type.Integer({ minimum: 0 }),
warn: Type.Integer({ minimum: 0 }),
info: Type.Integer({ minimum: 0 }),
findings: Type.Array(SkillProposalFindingSchema),
});
/** Skill file target that a proposal creates or updates. */
const SkillProposalTargetSchema = closedObject({
/** One applied Workshop change; `versionId` names the snapshot taken before it, for undo. */
export const SkillWorkshopChangeSchema = closedObject({
id: NonEmptyString,
agentId: NonEmptyString,
skillName: NonEmptyString,
skillKey: NonEmptyString,
skillDir: NonEmptyString,
skillFile: NonEmptyString,
source: Type.Optional(NonEmptyString),
currentContentHash: Type.Optional(NonEmptyString),
});
/** Optional runtime origin tying a proposal back to an agent turn. */
const SkillProposalOriginSchema = closedObject({
agentId: Type.Optional(NonEmptyString),
action: SkillWorkshopChangeActionSchema,
actor: SkillWorkshopActorSchema,
summary: Type.String(),
versionId: Type.Optional(NonEmptyString),
sessionKey: Type.Optional(NonEmptyString),
runId: Type.Optional(NonEmptyString),
messageId: Type.Optional(NonEmptyString),
createdAtMs: TimestampMsSchema,
});
const SkillProposalEvaluationFindingSchema = closedObject({
ruleId: Type.String({ minLength: 1, maxLength: 256 }),
severity: Type.Union([Type.Literal("info"), Type.Literal("warn"), Type.Literal("critical")]),
message: Type.String({ minLength: 1, maxLength: 4_000 }),
file: Type.Optional(Type.String({ minLength: 1, maxLength: 1_024 })),
line: Type.Optional(Type.Integer({ minimum: 1 })),
/** Live Workshop skill with optional usage counters. */
export const SkillWorkshopSkillSummarySchema = closedObject({
name: NonEmptyString,
description: Type.String(),
updatedAtMs: TimestampMsSchema,
sizeBytes: Type.Integer({ minimum: 0 }),
files: Type.Array(NonEmptyString),
useCount: Type.Optional(Type.Integer({ minimum: 0 })),
lastUsedAtMs: Type.Optional(TimestampMsSchema),
});
const SkillProposalEvaluationResultSchema = closedObject({
summary: Type.Optional(Type.String({ maxLength: 8_000 })),
findings: Type.Optional(Type.Array(SkillProposalEvaluationFindingSchema, { maxItems: 200 })),
metrics: Type.Optional(
Type.Record(
Type.String(),
Type.Union([Type.String({ maxLength: 4_000 }), Type.Number(), Type.Boolean()]),
{
maxProperties: 64,
propertyNames: Type.String({ minLength: 1, maxLength: 128 }),
},
),
/** Saved versions of one Workshop skill; `live: false` means the skill is archived. */
export const SkillWorkshopArchivedSkillSchema = closedObject({
name: NonEmptyString,
live: Type.Boolean(),
versions: Type.Array(
closedObject({
id: NonEmptyString,
action: SkillWorkshopChangeActionSchema,
createdAtMs: TimestampMsSchema,
}),
),
evaluatorVersion: Type.Optional(Type.String({ minLength: 1, maxLength: 128 })),
mode: Type.Optional(Type.String({ minLength: 1, maxLength: 128 })),
decision: Type.Optional(
Type.Union([Type.Literal("pass"), Type.Literal("revise"), Type.Literal("block")]),
),
decisionReason: Type.Optional(Type.String({ maxLength: 2_000 })),
});
const SkillProposalEvaluationOutcomeAttribution = {
pluginId: Type.String({ minLength: 1, maxLength: 128 }),
pluginVersion: Type.Optional(Type.String({ minLength: 1, maxLength: 128 })),
evaluatorId: Type.String({ minLength: 1, maxLength: 128 }),
};
const SkillProposalEvaluationOutcomeSchema = Type.Union([
closedObject({
...SkillProposalEvaluationOutcomeAttribution,
status: Type.Literal("completed"),
result: SkillProposalEvaluationResultSchema,
}),
closedObject({
...SkillProposalEvaluationOutcomeAttribution,
status: Type.Literal("skipped"),
}),
closedObject({
...SkillProposalEvaluationOutcomeAttribution,
status: Type.Literal("error"),
error: Type.String({ minLength: 1, maxLength: 2_000 }),
}),
]);
/** Latest completed evaluator run attached to a proposal record. */
export const SkillProposalEvaluationSchema = closedObject({
id: NonEmptyString,
proposedVersion: NonEmptyString,
revisionHash: Sha256String,
trigger: Type.Union([Type.Literal("manual"), Type.Literal("apply")]),
startedAt: NonEmptyString,
completedAt: NonEmptyString,
correlationId: Type.Optional(NonEmptyString),
targetTreeSha256: Type.Optional(Sha256String),
outcomes: Type.Array(SkillProposalEvaluationOutcomeSchema, { maxItems: 64 }),
});
/** Full persisted skill proposal record. */
const SkillProposalRecordSchema = closedObject({
schema: Type.Literal("openclaw.skill-workshop.proposal.v1"),
id: NonEmptyString,
kind: SkillProposalKindSchema,
status: SkillProposalStatusSchema,
title: NonEmptyString,
description: NonEmptyString,
createdAt: NonEmptyString,
updatedAt: NonEmptyString,
createdBy: SkillProposalSourceSchema,
origin: Type.Optional(SkillProposalOriginSchema),
proposedVersion: NonEmptyString,
draftFile: Type.Literal("PROPOSAL.md"),
draftHash: NonEmptyString,
supportFiles: Type.Optional(Type.Array(SkillProposalSupportFileSchema, { maxItems: 64 })),
target: SkillProposalTargetSchema,
scan: SkillProposalScanSchema,
goal: Type.Optional(Type.String()),
evidence: Type.Optional(Type.String()),
appliedAt: Type.Optional(NonEmptyString),
rejectedAt: Type.Optional(NonEmptyString),
quarantinedAt: Type.Optional(NonEmptyString),
staleAt: Type.Optional(NonEmptyString),
statusReason: Type.Optional(Type.String()),
evaluation: Type.Optional(SkillProposalEvaluationSchema),
});
/** Condensed proposal manifest entry for list views. */
const SkillProposalManifestEntrySchema = closedObject({
id: NonEmptyString,
kind: SkillProposalKindSchema,
status: SkillProposalStatusSchema,
title: NonEmptyString,
description: NonEmptyString,
skillName: NonEmptyString,
skillKey: NonEmptyString,
createdAt: NonEmptyString,
updatedAt: NonEmptyString,
scanState: SkillProposalScanStateSchema,
revisionHash: Type.Optional(Sha256String),
degradedState: Type.Optional(Type.Literal("draft-missing")),
});
/** Lists skill-workshop proposals for the selected agent scope. */
export const SkillsProposalsListParamsSchema = closedObject({
/** Lists the selected agent's Workshop skills and saved versions. */
export const SkillsWorkshopListParamsSchema = closedObject({
agentId: Type.Optional(NonEmptyString),
});
/** Proposal manifest response for dashboard/workshop list views. */
export const SkillsProposalsListResultSchema = closedObject({
schema: Type.Literal("openclaw.skill-workshop.proposals-manifest.v1"),
updatedAt: NonEmptyString,
proposals: Type.Array(SkillProposalManifestEntrySchema),
installedSkills: Type.Array(
closedObject({ name: NonEmptyString, skillKey: NonEmptyString, description: Type.String() }),
),
export const SkillsWorkshopListResultSchema = closedObject({
agentId: NonEmptyString,
mode: Type.Union([Type.Literal("off"), Type.Literal("auto")]),
root: NonEmptyString,
skills: Type.Array(SkillWorkshopSkillSummarySchema),
archived: Type.Array(SkillWorkshopArchivedSkillSchema),
});
/** Reads the current agent-owned Workshop skill, independently of its proposal history. */
/** Pages the Workshop change feed newest first. */
export const SkillsWorkshopChangesParamsSchema = closedObject({
agentId: Type.Optional(NonEmptyString),
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 500 })),
beforeMs: Type.Optional(TimestampMsSchema),
});
export const SkillsWorkshopChangesResultSchema = closedObject({
changes: Type.Array(SkillWorkshopChangeSchema),
});
/** Reads one file of a live Workshop skill, or of a saved version when `versionId` is set. */
export const SkillsWorkshopReadParamsSchema = closedObject({
agentId: Type.Optional(NonEmptyString),
name: NonEmptyString,
filePath: Type.Optional(NonEmptyString),
versionId: Type.Optional(NonEmptyString),
});
export const SkillsWorkshopReadResultSchema = closedObject({
name: NonEmptyString,
skillKey: NonEmptyString,
description: Type.String(),
filePath: NonEmptyString,
content: Type.String(),
files: Type.Array(NonEmptyString),
});
/** Reads a proposal record plus editable draft/support content. */
export const SkillsProposalInspectParamsSchema = closedObject({
agentId: Type.Optional(NonEmptyString),
proposalId: NonEmptyString,
});
/** Full proposal inspection result used before apply/revise decisions. */
export const SkillsProposalInspectResultSchema = closedObject({
record: SkillProposalRecordSchema,
revisionHash: Type.Optional(Sha256String),
content: Type.String(),
supportFiles: Type.Optional(Type.Array(SkillProposalSupportFileInputSchema, { maxItems: 64 })),
});
/** Creates a proposal for a new skill. */
export const SkillsProposalCreateParamsSchema = closedObject({
/** Archives a live Workshop skill; the snapshot stays restorable. */
export const SkillsWorkshopArchiveParamsSchema = closedObject({
agentId: Type.Optional(NonEmptyString),
name: NonEmptyString,
description: NonEmptyString,
content: SkillProposalContentString,
supportFiles: Type.Optional(Type.Array(SkillProposalSupportFileInputSchema, { maxItems: 64 })),
goal: Type.Optional(Type.String()),
evidence: Type.Optional(Type.String()),
reason: Type.Optional(Type.String({ minLength: 1, maxLength: 1_000 })),
});
/** Creates a proposal to update an existing skill. */
export const SkillsProposalUpdateParamsSchema = closedObject({
/** Restores a saved version (newest when omitted); the current live copy is versioned first. */
export const SkillsWorkshopRestoreParamsSchema = closedObject({
agentId: Type.Optional(NonEmptyString),
skillName: NonEmptyString,
description: Type.Optional(NonEmptyString),
content: SkillProposalContentString,
supportFiles: Type.Optional(Type.Array(SkillProposalSupportFileInputSchema, { maxItems: 64 })),
goal: Type.Optional(Type.String()),
evidence: Type.Optional(Type.String()),
name: NonEmptyString,
versionId: Type.Optional(NonEmptyString),
});
/** Replaces draft content/support files for an existing proposal. */
export const SkillsProposalReviseParamsSchema = closedObject({
agentId: Type.Optional(NonEmptyString),
proposalId: NonEmptyString,
expectedRevisionHash: Type.Optional(Sha256String),
correlationId: Type.Optional(Type.String({ minLength: 1, maxLength: 256 })),
content: Type.Optional(SkillProposalContentString),
supportFiles: Type.Optional(Type.Array(SkillProposalSupportFileInputSchema, { maxItems: 64 })),
description: Type.Optional(NonEmptyString),
goal: Type.Optional(Type.String()),
evidence: Type.Optional(Type.String()),
export const SkillsWorkshopChangeResultSchema = closedObject({
change: SkillWorkshopChangeSchema,
});
/** Starts an agent turn that revises a pending proposal from natural-language instructions. */
export const SkillsProposalRequestRevisionParamsSchema = closedObject({
agentId: Type.Optional(NonEmptyString),
targetAgentId: Type.Optional(NonEmptyString),
proposalId: NonEmptyString,
expectedRevisionHash: Sha256String,
instructions: Type.String({ minLength: 1, maxLength: 32_768 }),
sessionKey: NonEmptyString,
sessionId: Type.Optional(NonEmptyString),
idempotencyKey: NonEmptyString,
});
/** Chat-run acknowledgement returned after queueing a Skill Workshop revision request. */
export const SkillsProposalRequestRevisionResultSchema = Type.Object(
{
runId: NonEmptyString,
status: Type.Union([
Type.Literal("started"),
Type.Literal("in_flight"),
Type.Literal("ok"),
Type.Literal("timeout"),
Type.Literal("error"),
]),
},
{ additionalProperties: true },
);
/** Apply/reject payload bound to the exact proposal revision reviewed by the operator. */
export const SkillsProposalDecisionParamsSchema = closedObject({
agentId: Type.Optional(NonEmptyString),
proposalId: NonEmptyString,
expectedRevisionHash: Sha256String,
correlationId: Type.Optional(Type.String({ minLength: 1, maxLength: 256 })),
reason: Type.Optional(Type.String()),
});
/** Quarantine payload with optional optimistic-concurrency evidence. */
export const SkillsProposalActionParamsSchema = closedObject({
agentId: Type.Optional(NonEmptyString),
proposalId: NonEmptyString,
expectedRevisionHash: Type.Optional(Sha256String),
correlationId: Type.Optional(Type.String({ minLength: 1, maxLength: 256 })),
reason: Type.Optional(Type.String()),
});
/** Runs configured proposal evaluators against the current draft. */
export const SkillsProposalEvaluateParamsSchema = closedObject({
agentId: Type.Optional(NonEmptyString),
proposalId: NonEmptyString,
expectedRevisionHash: Type.Optional(Sha256String),
correlationId: Type.Optional(Type.String({ minLength: 1, maxLength: 256 })),
});
/** Updated proposal record and completed evaluator run returned by manual evaluation. */
export const SkillsProposalEvaluateResultSchema = closedObject({
record: SkillProposalRecordSchema,
evaluation: SkillProposalEvaluationSchema,
});
const SkillProposalLifecycleEventTypeSchema = Type.Union([
Type.Literal("created"),
Type.Literal("revised"),
Type.Literal("evaluation_completed"),
Type.Literal("applied"),
Type.Literal("rejected"),
Type.Literal("quarantined"),
Type.Literal("stale"),
]);
const SkillProposalLifecycleEventActorSchema = closedObject({
type: Type.Union([
Type.Literal("agent"),
Type.Literal("gateway"),
Type.Literal("plugin"),
Type.Literal("system"),
]),
id: Type.Optional(NonEmptyString),
});
const SkillProposalLifecycleEventPayloadSchema = Type.Record(
Type.String(),
Type.Union([Type.String({ maxLength: 4_000 }), Type.Number(), Type.Boolean(), Type.Null()]),
{
maxProperties: 32,
propertyNames: Type.String({ minLength: 1, maxLength: 80 }),
},
);
/** Durable Skill Workshop lifecycle event returned for replay. */
export const SkillProposalLifecycleEventSchema = closedObject({
sequence: Type.Integer({ minimum: 1 }),
eventId: NonEmptyString,
proposalId: NonEmptyString,
proposedVersion: NonEmptyString,
revisionHash: Sha256String,
type: SkillProposalLifecycleEventTypeSchema,
occurredAt: NonEmptyString,
actor: SkillProposalLifecycleEventActorSchema,
correlationId: Type.Optional(NonEmptyString),
payload: Type.Optional(SkillProposalLifecycleEventPayloadSchema),
evaluation: Type.Optional(SkillProposalEvaluationSchema),
});
/** Lists durable proposal lifecycle events after an optional sequence cursor. */
export const SkillsProposalEventsListParamsSchema = closedObject({
agentId: Type.Optional(NonEmptyString),
proposalId: Type.Optional(NonEmptyString),
afterSequence: Type.Optional(Type.Integer({ minimum: 0 })),
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 200 })),
});
/** Sequence-ordered proposal lifecycle replay page. */
export const SkillsProposalEventsListResultSchema = closedObject({
events: Type.Array(SkillProposalLifecycleEventSchema, { maxItems: 200 }),
nextSequence: Type.Optional(Type.Integer({ minimum: 1 })),
});
/** Result returned after applying a skill proposal to disk. */
export const SkillsProposalApplyResultSchema = closedObject({
record: SkillProposalRecordSchema,
targetSkillFile: NonEmptyString,
});
/** Proposal record result returned after non-apply proposal actions. */
export const SkillsProposalRecordResultSchema = SkillProposalRecordSchema;
export {
SkillsCuratorStatusParamsSchema,
SkillsCuratorStatusResultSchema,
SkillsCuratorActionParamsSchema,
SkillsCuratorActionResultSchema,
SkillCuratorLiveEntrySchema,
SkillsCuratorLiveStatusResultSchema,
} from "./skill-curator.js";
export const GitHubIdentityScopeSchema = Type.Union([
Type.Literal("system"),
Type.Literal("agent"),
@ -1080,37 +795,18 @@ export type SkillsBinsParams = Static<typeof SkillsBinsParamsSchema>;
export type SkillsBinsResult = Static<typeof SkillsBinsResultSchema>;
export type SkillsDetailParams = Static<typeof SkillsDetailParamsSchema>;
export type SkillsDetailResult = Static<typeof SkillsDetailResultSchema>;
export type SkillsProposalsListParams = Static<typeof SkillsProposalsListParamsSchema>;
export type SkillsProposalsListResult = Static<typeof SkillsProposalsListResultSchema>;
export type SkillsProposalInspectParams = Static<typeof SkillsProposalInspectParamsSchema>;
export type SkillsProposalInspectResult = Static<typeof SkillsProposalInspectResultSchema>;
export type SkillsProposalCreateParams = Static<typeof SkillsProposalCreateParamsSchema>;
export type SkillsProposalUpdateParams = Static<typeof SkillsProposalUpdateParamsSchema>;
export type SkillsProposalReviseParams = Static<typeof SkillsProposalReviseParamsSchema>;
export type SkillsProposalRequestRevisionParams = Static<
typeof SkillsProposalRequestRevisionParamsSchema
>;
export type SkillsProposalRequestRevisionResult = Static<
typeof SkillsProposalRequestRevisionResultSchema
>;
export type SkillsProposalDecisionParams = Static<typeof SkillsProposalDecisionParamsSchema>;
export type SkillsProposalActionParams = Static<typeof SkillsProposalActionParamsSchema>;
export type SkillProposalEvaluation = Static<typeof SkillProposalEvaluationSchema>;
export type SkillsProposalEvaluateParams = Static<typeof SkillsProposalEvaluateParamsSchema>;
export type SkillsProposalEvaluateResult = Static<typeof SkillsProposalEvaluateResultSchema>;
export type SkillProposalLifecycleEvent = Static<typeof SkillProposalLifecycleEventSchema>;
export type SkillsProposalEventsListParams = Static<typeof SkillsProposalEventsListParamsSchema>;
export type SkillsProposalEventsListResult = Static<typeof SkillsProposalEventsListResultSchema>;
export type SkillsProposalApplyResult = Static<typeof SkillsProposalApplyResultSchema>;
export type SkillsProposalRecordResult = Static<typeof SkillsProposalRecordResultSchema>;
export type {
SkillsCuratorStatusParams,
SkillsCuratorStatusResult,
SkillsCuratorLiveStatusResult,
SkillsCuratorCompatibleStatusResult,
SkillsCuratorActionParams,
SkillsCuratorActionResult,
} from "./skill-curator.js";
export type SkillWorkshopChange = Static<typeof SkillWorkshopChangeSchema>;
export type SkillWorkshopSkillSummary = Static<typeof SkillWorkshopSkillSummarySchema>;
export type SkillWorkshopArchivedSkill = Static<typeof SkillWorkshopArchivedSkillSchema>;
export type SkillsWorkshopListParams = Static<typeof SkillsWorkshopListParamsSchema>;
export type SkillsWorkshopListResult = Static<typeof SkillsWorkshopListResultSchema>;
export type SkillsWorkshopChangesParams = Static<typeof SkillsWorkshopChangesParamsSchema>;
export type SkillsWorkshopChangesResult = Static<typeof SkillsWorkshopChangesResultSchema>;
export type SkillsWorkshopReadParams = Static<typeof SkillsWorkshopReadParamsSchema>;
export type SkillsWorkshopReadResult = Static<typeof SkillsWorkshopReadResultSchema>;
export type SkillsWorkshopArchiveParams = Static<typeof SkillsWorkshopArchiveParamsSchema>;
export type SkillsWorkshopRestoreParams = Static<typeof SkillsWorkshopRestoreParamsSchema>;
export type SkillsWorkshopChangeResult = Static<typeof SkillsWorkshopChangeResultSchema>;
export type SkillsSecurityVerdictsParams = Static<typeof SkillsSecurityVerdictsParamsSchema>;
export type SkillsSecurityVerdictsResult = Static<typeof SkillsSecurityVerdictsResultSchema>;
export type SkillsSkillCardParams = Static<typeof SkillsSkillCardParamsSchema>;
@ -1120,4 +816,3 @@ export type SkillsUploadChunkParams = Static<typeof SkillsUploadChunkParamsSchem
export type SkillsUploadCommitParams = Static<typeof SkillsUploadCommitParamsSchema>;
export type SkillsInstallParams = Static<typeof SkillsInstallParamsSchema>;
export type SkillsUpdateParams = Static<typeof SkillsUpdateParamsSchema>;
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */

View file

@ -13,9 +13,7 @@ import {
OutboundDeliveryQueuedErrorDetailsSchema,
SessionWorkspaceRecoveryRequiredErrorDetailsSchema,
ProjectCloneErrorDetailsSchema,
SkillProposalRevisionChangedErrorDetailsSchema,
missingScopeErrorShape,
readSkillProposalRevisionChangedError,
readMissingScopeError,
readMissingScopeErrorDetails,
readCronJobNotFoundError,
@ -158,23 +156,6 @@ describe("gateway error details", () => {
);
});
it("validates and reads changed skill proposal revisions", () => {
const details = {
code: GatewayErrorDetailCodes.SKILL_PROPOSAL_REVISION_CHANGED,
expectedRevisionHash: "A".repeat(64),
currentRevisionHash: "b".repeat(64),
};
expect(Value.Check(SkillProposalRevisionChangedErrorDetailsSchema, details)).toBe(true);
expect(Value.Check(GatewayErrorDetailsSchema, details)).toBe(true);
expect(readSkillProposalRevisionChangedError({ details })).toEqual(details);
expect(
readSkillProposalRevisionChangedError({
details: { ...details, currentRevisionHash: "not-a-sha256" },
}),
).toBeNull();
});
it("builds a distinct forbidden missing-scope response", () => {
expect(
missingScopeErrorShape({

View file

@ -19,7 +19,6 @@ export {
type McpAppViewExpiredErrorDetails,
type OutboundDeliveryQueuedErrorDetails,
type MissingScopeErrorDetails,
type SkillProposalRevisionChangedErrorDetails,
type UserPrefsLimitExceededErrorDetails,
type ProjectCloneErrorDetails,
type ProjectCloneFailureCause,
@ -34,8 +33,6 @@ export {
isMcpAppViewExpiredError,
readMissingScopeError,
readMissingScopeErrorDetails,
buildSkillProposalRevisionChangedErrorDetails,
readSkillProposalRevisionChangedError,
} from "../gateway-error-details.js";
export const CronJobNotFoundErrorDetailsSchema = closedObject({
@ -89,14 +86,6 @@ export const ProjectCloneErrorDetailsSchema = closedObject({
}),
});
const RevisionHashSchema = Type.String({ pattern: "^[a-fA-F0-9]{64}$" });
export const SkillProposalRevisionChangedErrorDetailsSchema = closedObject({
code: Type.Literal(GatewayErrorDetailCodes.SKILL_PROPOSAL_REVISION_CHANGED),
expectedRevisionHash: RevisionHashSchema,
currentRevisionHash: RevisionHashSchema,
});
export const SessionWorkspaceRecoveryRequiredErrorDetailsSchema = closedObject({
code: Type.Literal(GatewayErrorDetailCodes.SESSION_WORKSPACE_RECOVERY_REQUIRED),
cause: Type.Literal("device_offline"),
@ -122,7 +111,6 @@ export const GatewayErrorDetailsSchema = Type.Union([
McpAppViewExpiredErrorDetailsSchema,
OutboundDeliveryQueuedErrorDetailsSchema,
UserPrefsLimitExceededErrorDetailsSchema,
SkillProposalRevisionChangedErrorDetailsSchema,
ProjectCloneErrorDetailsSchema,
UnknownAgentIdErrorDetailsSchema,
WizardNotFoundErrorDetailsSchema,

View file

@ -107,8 +107,6 @@ const EXCLUDED_SCHEMA_EXPORTS = [
"SessionsReclaimParamsSchema",
"SessionsReclaimResultPlacementSchema",
"SessionsReclaimResultSchema",
"SkillProposalEvaluationSchema",
"SkillProposalLifecycleEventSchema",
"SystemAgentChatQuestionSchema",
"SystemAgentWizardCancelSchema",
"TranscriptUtteranceSchema",

View file

@ -1,99 +0,0 @@
import type { Static } from "typebox";
import { Type } from "typebox";
import { closedObject } from "./closed-object.js";
import { NonEmptyString } from "./primitives.js";
const SkillLifecycleStateSchema = Type.Union([
Type.Literal("active"),
Type.Literal("stale"),
Type.Literal("archived"),
]);
const SkillCuratorEntrySchema = closedObject({
skillFile: NonEmptyString,
skillKey: NonEmptyString,
skillName: NonEmptyString,
state: SkillLifecycleStateSchema,
pinned: Type.Boolean(),
createdAtMs: Type.Number(),
stateChangedAtMs: Type.Number(),
lastUsedAtMs: Type.Union([Type.Number(), Type.Null()]),
useCount: Type.Number(),
archivedReason: Type.Union([Type.String(), Type.Null()]),
});
const SkillOverlapCandidateSchema = closedObject({
left: NonEmptyString,
right: NonEmptyString,
score: Type.Number(),
});
const SkillCollectionReviewStatusSchema = closedObject({
attemptedAtMs: Type.Number(),
succeededAtMs: Type.Optional(Type.Number()),
error: Type.Optional(Type.String()),
});
const SkillExperienceReviewStatusSchema = closedObject({
attemptedAtMs: Type.Number(),
outcome: Type.Union([
Type.Literal("completed"),
Type.Literal("applied"),
Type.Literal("proposed"),
Type.Literal("nothing"),
Type.Literal("failed"),
]),
proposalId: Type.Optional(Type.String()),
error: Type.Optional(Type.String()),
usage: Type.Optional(
closedObject({
inputTokens: Type.Number(),
cachedInputTokens: Type.Number(),
outputTokens: Type.Number(),
}),
),
});
/** Reads persisted skill usage and collection review state. */
export const SkillsCuratorStatusParamsSchema = closedObject({});
export const SkillsCuratorStatusResultSchema = closedObject({
lastAttemptAtMs: Type.Union([Type.Number(), Type.Null()]),
lastSuccessAtMs: Type.Union([Type.Number(), Type.Null()]),
lastError: Type.Union([Type.String(), Type.Null()]),
collectionReview: Type.Optional(Type.Record(NonEmptyString, SkillCollectionReviewStatusSchema)),
experienceReview: Type.Optional(Type.Record(NonEmptyString, SkillExperienceReviewStatusSchema)),
counts: closedObject({
active: Type.Number(),
stale: Type.Number(),
archived: Type.Number(),
}),
skills: Type.Array(SkillCuratorEntrySchema),
overlaps: Type.Array(SkillOverlapCandidateSchema),
});
/** Preserves retired curator action methods so clients receive an actionable error. */
export const SkillsCuratorActionParamsSchema = closedObject({ skill: NonEmptyString });
export const SkillsCuratorActionResultSchema = SkillCuratorEntrySchema;
export const SkillCuratorLiveEntrySchema = closedObject({
...SkillCuratorEntrySchema.properties,
createdAtMs: Type.Union([Type.Number(), Type.Null()]),
stateChangedAtMs: Type.Union([Type.Number(), Type.Null()]),
});
export const SkillsCuratorLiveStatusResultSchema = closedObject({
...SkillsCuratorStatusResultSchema.properties,
inventory: Type.Literal("live-workshop"),
skills: Type.Array(SkillCuratorLiveEntrySchema),
});
export type SkillsCuratorStatusParams = Static<typeof SkillsCuratorStatusParamsSchema>;
export type SkillsCuratorStatusResult = Static<typeof SkillsCuratorStatusResultSchema>;
export type SkillsCuratorLiveStatusResult = Static<typeof SkillsCuratorLiveStatusResultSchema>;
export type SkillsCuratorCompatibleStatusResult =
| SkillsCuratorStatusResult
| SkillsCuratorLiveStatusResult;
export type SkillsCuratorActionParams = Static<typeof SkillsCuratorActionParamsSchema>;
export type SkillsCuratorActionResult = Static<typeof SkillsCuratorActionResultSchema>;

View file

@ -1,40 +0,0 @@
import type { Static } from "typebox";
import { Type } from "typebox";
import { lazyCompile } from "../protocol-validator.js";
import { closedObject } from "./closed-object.js";
import { NonEmptyString } from "./primitives.js";
export const SkillsProposalHistoryStatusParamsSchema = closedObject({
agentId: Type.Optional(NonEmptyString),
});
export const SkillsProposalHistoryScanParamsSchema = closedObject({
agentId: Type.Optional(NonEmptyString),
direction: Type.Optional(Type.Union([Type.Literal("older"), Type.Literal("newer")])),
});
export const SkillsProposalHistoryScanResultSchema = closedObject({
schema: Type.Literal("openclaw.skill-workshop.history-scan.v1"),
hasScanned: Type.Boolean(),
reviewedSessions: Type.Integer({ minimum: 0 }),
ideasFound: Type.Integer({ minimum: 0 }),
hasMore: Type.Boolean(),
lastScanReviewed: Type.Integer({ minimum: 0 }),
lastScanIdeas: Type.Integer({ minimum: 0 }),
lastScanAt: Type.Optional(NonEmptyString),
oldestReviewedAt: Type.Optional(NonEmptyString),
newestReviewedAt: Type.Optional(NonEmptyString),
});
export type SkillsProposalHistoryStatusParams = Static<
typeof SkillsProposalHistoryStatusParamsSchema
>;
export type SkillsProposalHistoryScanParams = Static<typeof SkillsProposalHistoryScanParamsSchema>;
export type SkillsProposalHistoryScanResult = Static<typeof SkillsProposalHistoryScanResultSchema>;
export const validateSkillsProposalHistoryStatusParams = lazyCompile(
SkillsProposalHistoryStatusParamsSchema,
);
export const validateSkillsProposalHistoryScanParams = lazyCompile(
SkillsProposalHistoryScanParamsSchema,
);

View file

@ -473,23 +473,11 @@ export const validateSkillsUploadCommitParams = compile(S.SkillsUploadCommitPara
export const validateSkillsUpdateParams = compile(S.SkillsUpdateParamsSchema);
export const validateSkillsSearchParams = compile(S.SkillsSearchParamsSchema);
export const validateSkillsDetailParams = compile(S.SkillsDetailParamsSchema);
export const validateSkillsCuratorStatusParams = compile(S.SkillsCuratorStatusParamsSchema);
export const validateSkillsCuratorActionParams = compile(S.SkillsCuratorActionParamsSchema);
export const validateSkillsProposalsListParams = compile(S.SkillsProposalsListParamsSchema);
export const validateSkillsWorkshopListParams = compile(S.SkillsWorkshopListParamsSchema);
export const validateSkillsWorkshopChangesParams = compile(S.SkillsWorkshopChangesParamsSchema);
export const validateSkillsWorkshopReadParams = compile(S.SkillsWorkshopReadParamsSchema);
export const validateSkillsProposalInspectParams = compile(S.SkillsProposalInspectParamsSchema);
export const validateSkillsProposalCreateParams = compile(S.SkillsProposalCreateParamsSchema);
export const validateSkillsProposalUpdateParams = compile(S.SkillsProposalUpdateParamsSchema);
export const validateSkillsProposalReviseParams = compile(S.SkillsProposalReviseParamsSchema);
export const validateSkillsProposalRequestRevisionParams = compile(
S.SkillsProposalRequestRevisionParamsSchema,
);
export const validateSkillsProposalDecisionParams = compile(S.SkillsProposalDecisionParamsSchema);
export const validateSkillsProposalActionParams = compile(S.SkillsProposalActionParamsSchema);
export const validateSkillsProposalEvaluateParams = compile(S.SkillsProposalEvaluateParamsSchema);
export const validateSkillsProposalEventsListParams = compile(
S.SkillsProposalEventsListParamsSchema,
);
export const validateSkillsWorkshopArchiveParams = compile(S.SkillsWorkshopArchiveParamsSchema);
export const validateSkillsWorkshopRestoreParams = compile(S.SkillsWorkshopRestoreParamsSchema);
export const validateSkillsSecurityVerdictsParams = compile(S.SkillsSecurityVerdictsParamsSchema);
export const validateSkillsSkillCardParams = compile(S.SkillsSkillCardParamsSchema);
export const validateCronListParams = compile(S.CronListParamsSchema);

3
pnpm-lock.yaml generated
View file

@ -2915,9 +2915,6 @@ importers:
'@tanstack/virtual-core':
specifier: 3.17.11
version: 3.17.11
diff:
specifier: 9.0.0
version: 9.0.0
dompurify:
specifier: 3.4.16
version: 3.4.16

View file

@ -8,7 +8,7 @@ scenario:
- plugins.activation-skills
secondary:
- channels.qa-channel-outbound
objective: Verify a live model changes a user-owned workspace skill in place when its owner asks in chat, instead of refusing or only filing a Workshop proposal.
objective: Verify a live model changes a user-owned workspace skill in place when its owner asks in chat, instead of refusing or copying it into the Workshop.
gatewayConfigPatch:
commands:
ownerAllowFrom: [qa-channel:alice]
@ -20,7 +20,7 @@ scenario:
- docs/tools/skill-workshop/how-it-works.md
codeRefs:
- src/agents/skill-workshop-prompt.ts
- src/agents/tools/skill-workshop-tool-description.ts
- src/agents/tools/skill-workshop-tool.ts
execution:
kind: flow
summary: Seed a workspace skill, ask a live model to change it, and read the skill file afterward.

View file

@ -487,10 +487,6 @@ const ALLOWED_UI_LITERALS = new Map<string, ReadonlySet<string>>([
]),
],
["apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", new Set(["Off"])],
[
"apps/android/app/src/main/java/ai/openclaw/app/ui/SkillWorkshopSettingsScreen.kt",
new Set(["all", "applied", "held", "pending", "rejected"]),
],
[
"apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayDiagnostics.kt",
new Set(["$versionName-dev"]),

View file

@ -155,8 +155,8 @@ const ownerModules = [
...schemaModulesSource.matchAll(/^export \* from "\.\/schema\/([^"]+)\.js";$/gmu),
].map(([, moduleName = ""]) => moduleName);
check(
ownerModules.length === 74 && new Set(ownerModules).size === ownerModules.length,
"schema-modules.ts must contain one unique 74-module owner list",
ownerModules.length === 73 && new Set(ownerModules).size === ownerModules.length,
"schema-modules.ts must contain one unique 73-module owner list",
);
check(
schemaModulesSource.split("\n").filter(Boolean).length === ownerModules.length,

View file

@ -74,10 +74,7 @@ import {
} from "./control-ui-mock-plugins.ts";
import { createControlUiPreviewInitScript } from "./control-ui-mock-preview.ts";
import { skillLibraryMockInitScript } from "./control-ui-mock-skill-library.ts";
import {
buildSkillWorkshopMocks,
skillWorkshopMockInitScript,
} from "./control-ui-mock-skill-workshop.js";
import { skillWorkshopMockInitScript } from "./control-ui-mock-skill-workshop.js";
import { buildProfileUsageMocks } from "./control-ui-mock-usage.ts";
const FIXTURES = [
@ -2069,7 +2066,6 @@ async function createChatPickerScenario(
// heatmap stay filled no matter when the mock harness runs.
const profileUsage = buildProfileUsageMocks(Date.now());
const modelProviders = buildModelProviderMocks(Date.now());
const skillWorkshop = buildSkillWorkshopMocks(Date.now());
const richAttention = fixture === "approval";
const cronMocks = buildCronMocks(Date.now(), {
richAttention,
@ -2294,15 +2290,11 @@ async function createChatPickerScenario(
"sessions.search",
"session.reactions.list",
"session.reactions.set",
"skills.workshop.archive",
"skills.workshop.changes",
"skills.workshop.list",
"skills.workshop.read",
"skills.proposals.apply",
"skills.proposals.evaluate",
"skills.proposals.historyScan",
"skills.proposals.historyStatus",
"skills.proposals.inspect",
"skills.proposals.list",
"skills.proposals.reject",
"skills.proposals.requestRevision",
"skills.workshop.restore",
"skills.library.activate",
"skills.library.import",
"skills.library.list",
@ -2756,7 +2748,6 @@ async function createChatPickerScenario(
"wizard.start": channelWizard.start,
"wizard.next": channelWizard.next,
"wizard.cancel": { status: "cancelled" },
"skills.proposals.requestRevision": skillWorkshop.requestRevision,
"usage.cost": profileUsage.cost,
"sessions.usage": profileUsage.sessions,
"models.authStatus": modelAuthStatus,

View file

@ -1,273 +1,298 @@
import type {
SkillsProposalEvaluateResult,
SkillsProposalInspectResult,
SkillsProposalRecordResult,
SkillsProposalsListResult,
SkillWorkshopChange,
SkillWorkshopSkillSummary,
SkillsWorkshopChangesResult,
SkillsWorkshopListResult,
} from "../packages/gateway-protocol/src/schema/agents-models-skills.js";
import type { ControlUiMockGateway } from "../ui/src/test-helpers/control-ui-e2e.ts";
export function buildSkillWorkshopMocks(baseTime: number) {
type MockWorkshopSkill = { summary: SkillWorkshopSkillSummary; content: string };
type MockWorkshopVersion = {
id: string;
action: SkillWorkshopChange["action"];
createdAtMs: number;
skill: MockWorkshopSkill;
};
type SkillWorkshopMockSeed = {
skills: MockWorkshopSkill[];
versions: Record<string, MockWorkshopVersion[]>;
changes: SkillWorkshopChange[];
};
function buildSkillWorkshopMocks(baseTime: number): SkillWorkshopMockSeed {
const hour = 60 * 60 * 1000;
const day = 24 * hour;
const proposals = [
const skill = (
name: string,
description: string,
steps: string[],
updatedAtMs: number,
useCount = 3,
) => {
const content = [
"---",
`name: ${name}`,
`description: ${description}`,
"---",
"",
...steps.map((step, index) => `${index + 1}. ${step}`),
"",
].join("\n");
return {
summary: {
name,
description,
updatedAtMs,
sizeBytes: content.length,
files: ["SKILL.md"],
useCount,
lastUsedAtMs: updatedAtMs + hour,
},
content,
};
};
const skills = [
skill(
"release-notes",
"Use when drafting release notes from merged PRs; group by user impact.",
[
"List merged PRs since the last tag with `gh pr list --state merged`.",
"Group entries by user-visible impact, not by package.",
"Link each entry to its PR once.",
],
baseTime - 2 * hour,
12,
),
skill(
"flaky-test-triage",
"Use when a CI test fails intermittently; rerun in isolation before blaming the change.",
[
"Rerun the failing test alone three times before reading the diff.",
"Compare timing and ordering between passing and failing runs.",
"Quarantine only with a linked issue.",
],
baseTime - 5 * hour,
0,
),
skill(
"budget-reconciliation",
"Use when reconciling the monthly budget; match bank CSV rows before categorizing.",
[
"Import the bank CSV before editing categories.",
"Match transfers by amount and date within one day.",
"Flag unmatched rows instead of guessing a category.",
],
baseTime - day,
),
];
const changes: SkillWorkshopChange[] = [
{
id: "prop-release-tweets",
kind: "update",
status: "pending",
title: "Tighten release tweet drafting",
description: "Capture the changelog-to-tweet flow the agent keeps re-deriving.",
skillName: "release-tweets",
skillKey: "release-tweets",
createdAt: new Date(baseTime - 2 * hour).toISOString(),
updatedAt: new Date(baseTime - hour).toISOString(),
scanState: "clean",
id: "change-release-notes-patch",
agentId: "main",
skillName: "release-notes",
action: "patch",
actor: "review",
summary: "tightened PR grouping step",
versionId: "20260101T000000000Z-patch",
createdAtMs: baseTime - 2 * hour,
},
{
id: "prop-crawler-etiquette",
kind: "create",
status: "pending",
title: "Add crawler etiquette skill",
description: "Rate limits and robots.txt handling learned during the docs sweep.",
skillName: "crawler-etiquette",
skillKey: "crawler-etiquette",
createdAt: new Date(baseTime - 3 * day).toISOString(),
updatedAt: new Date(baseTime - 2 * day).toISOString(),
scanState: "clean",
id: "change-flaky-test-triage-create",
agentId: "main",
skillName: "flaky-test-triage",
action: "create",
actor: "review",
summary: "learned from a CI flake hunt",
createdAtMs: baseTime - 5 * hour,
},
{
id: "prop-changelog-style",
kind: "update",
status: "applied",
title: "Changelog bullet style",
description: "One bullet per entry, no hard wraps.",
skillName: "changelog-style",
skillKey: "changelog-style",
createdAt: new Date(baseTime - 6 * day).toISOString(),
updatedAt: new Date(baseTime - 5 * day).toISOString(),
scanState: "clean",
id: "change-budget-create",
agentId: "main",
skillName: "budget-reconciliation",
action: "create",
actor: "agent",
summary: "monthly budget reconciliation",
createdAtMs: baseTime - day,
},
] satisfies SkillsProposalsListResult["proposals"];
const revisionHash = "b".repeat(64);
const recordFor = (proposal: (typeof proposals)[number]): SkillsProposalRecordResult => ({
schema: "openclaw.skill-workshop.proposal.v1",
id: proposal.id,
kind: proposal.kind,
status: proposal.status,
title: proposal.title,
description: proposal.description,
createdAt: proposal.createdAt,
updatedAt: proposal.updatedAt,
createdBy: "skill-workshop",
proposedVersion: "2",
draftFile: "PROPOSAL.md",
draftHash: "a".repeat(64),
target: {
source: "openclaw-workshop",
skillName: proposal.skillName,
skillKey: proposal.skillKey,
skillDir: `.agents/skills/${proposal.skillKey}`,
skillFile: `.agents/skills/${proposal.skillKey}/SKILL.md`,
{
id: "change-standup-archive",
agentId: "main",
skillName: "standup-summary",
action: "archive",
actor: "user",
summary: "no longer posting standups",
versionId: "20251229T000000000Z-archive",
createdAtMs: baseTime - 3 * day,
},
scan: {
state: proposal.scanState,
scannedAt: new Date(baseTime - hour).toISOString(),
critical: 0,
warn: 0,
info: 0,
findings: [],
},
});
const evaluation: SkillsProposalEvaluateResult["evaluation"] = {
id: "evaluation-control-ui-mock",
proposedVersion: "2",
revisionHash,
trigger: "manual",
startedAt: new Date(baseTime - 20_000).toISOString(),
completedAt: new Date(baseTime - 18_000).toISOString(),
outcomes: [
];
// The review's patch saved the pre-change copy, so its Undo has something to restore.
const releaseNotesBeforePatch = skill(
"release-notes",
"Use when drafting release notes from merged PRs.",
["List merged PRs since the last tag.", "Group entries by package."],
baseTime - 3 * day,
);
const versions = {
"release-notes": [
{
pluginId: "fixture-quality",
pluginVersion: "1.0.0",
evaluatorId: "readability",
status: "completed",
result: {
summary: "The workflow is bounded and includes a recovery step.",
decision: "pass",
decisionReason: "No blocking findings in the sanitized fixture.",
},
id: "20260101T000000000Z-patch",
action: "patch" as const,
createdAtMs: baseTime - 2 * hour,
skill: releaseNotesBeforePatch,
},
],
"standup-summary": [
{
id: "20251229T000000000Z-archive",
action: "archive" as const,
createdAtMs: baseTime - 3 * day,
skill: skill(
"standup-summary",
"Use when summarizing yesterday's work for the team standup.",
["Collect merged PRs and closed issues from the last day.", "Keep it to three bullets."],
baseTime - 9 * day,
),
},
],
};
return {
list: {
schema: "openclaw.skill-workshop.proposals-manifest.v1",
updatedAt: new Date(baseTime - hour).toISOString(),
proposals,
installedSkills: proposals
.filter((proposal) => proposal.status === "applied")
.map((proposal) => ({
name: proposal.skillName,
skillKey: proposal.skillKey,
description: proposal.description,
})),
} satisfies SkillsProposalsListResult,
inspect: {
cases: proposals.map((proposal) => ({
match: { proposalId: proposal.id },
response: {
record: {
...recordFor(proposal),
...(proposal.id === "prop-release-tweets" ? { evaluation } : {}),
},
revisionHash,
content: [
`# ${proposal.title}`,
"",
proposal.description,
"",
"## Steps",
"1. Gather the source material.",
"2. Apply the documented workflow.",
].join("\n"),
supportFiles: [],
},
})),
},
evaluation,
requestRevision: { runId: "skill-workshop-revision-mock", status: "started" },
};
return { skills, versions, changes };
}
/** Each agent's proposal records own both mutation replies and subsequent reads. */
function installSkillWorkshopMock(seed: ReturnType<typeof buildSkillWorkshopMocks>): void {
/** Each agent's Workshop owns its skills, versions, and change feed. */
function installSkillWorkshopMock(seed: SkillWorkshopMockSeed): void {
const gateway = (window as Window & { openclawControlUiE2eGateway?: ControlUiMockGateway })
.openclawControlUiE2eGateway;
if (!gateway) {
return;
}
const scopes = new Map<
string,
{
list: SkillsProposalsListResult;
details: Map<string, SkillsProposalInspectResult>;
type Scope = {
skills: Map<string, MockWorkshopSkill>;
versions: Map<string, MockWorkshopVersion[]>;
changes: SkillWorkshopChange[];
};
const scopes = new Map<string, Scope>();
const scopeFor = (agentId: string): Scope => {
let scope = scopes.get(agentId);
if (!scope) {
scope = {
skills: new Map(seed.skills.map((entry) => [entry.summary.name, structuredClone(entry)])),
versions: new Map(Object.entries(structuredClone(seed.versions))),
changes: structuredClone(seed.changes).map((change) => Object.assign(change, { agentId })),
};
scopes.set(agentId, scope);
}
>();
for (const method of [
"list",
"inspect",
"evaluate",
"apply",
"reject",
"historyStatus",
"historyScan",
"read",
]) {
gateway.setRequestHandler(
method === "read" ? "skills.workshop.read" : `skills.proposals.${method}`,
({ params: input, respond }) => {
const reject = (message: string) =>
respond({ __mockError: { code: "INVALID_REQUEST", message } });
if (method === "historyStatus" || method === "historyScan") {
return reject(
"Historical batch scans are retired. Start a learning session from Workshop to review past conversations.",
);
}
const params = (input ?? {}) as {
agentId?: string;
proposalId?: string;
expectedRevisionHash?: string;
name?: string;
};
const agentId = params.agentId ?? "main";
let scope = scopes.get(agentId);
if (!scope) {
scope = {
list: structuredClone(seed.list),
details: new Map(
seed.inspect.cases.map((entry) => [
entry.match.proposalId,
structuredClone(entry.response),
]),
),
};
scopes.set(agentId, scope);
}
if (method === "list") {
respond(scope.list);
return;
}
if (method === "read") {
const installed = scope.list.installedSkills.find((skill) => skill.name === params.name);
const detail =
installed &&
Array.from(scope.details.values()).find(
(candidate) =>
candidate.record.status === "applied" &&
candidate.record.target.skillKey === installed.skillKey,
);
if (!installed || !detail) {
return reject("Mock Workshop skill not found.");
}
respond({ ...installed, content: detail.content });
return;
}
const detail = params.proposalId ? scope.details.get(params.proposalId) : undefined;
if (!detail) {
return reject("Unknown mock proposal; refresh the Workshop.");
}
if (method === "inspect") {
respond(detail);
return;
}
if (detail.record.status !== "pending") {
return reject("Only pending proposals can be evaluated, applied, or rejected.");
}
if (params.expectedRevisionHash && params.expectedRevisionHash !== detail.revisionHash) {
return reject("The proposal revision changed; refresh the proposal before retrying.");
}
const now = new Date().toISOString();
detail.record.updatedAt = now;
if (method === "evaluate") {
const evaluation = structuredClone(seed.evaluation);
evaluation.startedAt = now;
evaluation.completedAt = now;
detail.record.evaluation = evaluation;
} else if (method === "apply") {
detail.record.status = "applied";
detail.record.appliedAt = now;
const skillKey = detail.record.target.skillKey;
const installed = {
name: detail.record.kind === "create" ? skillKey : detail.record.target.skillName,
skillKey,
description: detail.record.description,
};
scope.list.installedSkills = [
...scope.list.installedSkills.filter((skill) => skill.skillKey !== skillKey),
installed,
];
} else {
detail.record.status = "rejected";
detail.record.rejectedAt = now;
}
const entry = scope.list.proposals.find((proposal) => proposal.id === detail.record.id);
if (entry) {
entry.status = detail.record.status;
entry.updatedAt = now;
entry.revisionHash = detail.revisionHash;
}
scope.list.updatedAt = now;
if (method === "evaluate") {
respond({ record: detail.record, evaluation: detail.record.evaluation });
} else if (method === "apply") {
respond({
record: detail.record,
targetSkillFile: `.agents/skills/${detail.record.target.skillKey}/SKILL.md`,
});
} else {
respond(detail.record);
}
},
);
return scope;
};
const reject = (respond: (payload: unknown) => void, message: string) =>
respond({ __mockError: { code: "INVALID_REQUEST", message } });
const record = (
scope: Scope,
agentId: string,
name: string,
action: SkillWorkshopChange["action"],
summary: string,
): SkillWorkshopChange => {
const now = Date.now();
const live = scope.skills.get(name);
let versionId: string | undefined;
if (live) {
versionId = `${new Date(now).toISOString().replace(/[-:.]/g, "")}-${action}`;
scope.versions.set(name, [
{ id: versionId, action, createdAtMs: now, skill: structuredClone(live) },
...(scope.versions.get(name) ?? []),
]);
}
const change: SkillWorkshopChange = {
id: `change-${name}-${now}`,
agentId,
skillName: name,
action,
actor: "user",
summary,
...(versionId ? { versionId } : {}),
createdAtMs: now,
};
scope.changes.unshift(change);
return change;
};
const handlers: Record<
string,
(params: Record<string, unknown>, agentId: string, respond: (payload: unknown) => void) => void
> = {
"skills.workshop.list": (_params, agentId, respond) => {
const scope = scopeFor(agentId);
const result: SkillsWorkshopListResult = {
agentId,
mode: "auto",
root: `~/.openclaw/agents/${agentId}/agent/workshop-skills`,
skills: [...scope.skills.values()].map((entry) => entry.summary),
archived: [...scope.versions.entries()].map(([name, versions]) => ({
name,
live: scope.skills.has(name),
versions: versions.map(({ id, action, createdAtMs }) => ({ id, action, createdAtMs })),
})),
};
respond(result);
},
"skills.workshop.changes": (params, agentId, respond) => {
const limit = typeof params.limit === "number" ? params.limit : 50;
const beforeMs = typeof params.beforeMs === "number" ? params.beforeMs : Infinity;
const result: SkillsWorkshopChangesResult = {
changes: scopeFor(agentId)
.changes.filter((change) => change.createdAtMs < beforeMs)
.slice(0, limit),
};
respond(result);
},
"skills.workshop.read": (params, agentId, respond) => {
const scope = scopeFor(agentId);
const name = typeof params.name === "string" ? params.name : "";
const skill =
typeof params.versionId === "string"
? scope.versions.get(name)?.find((version) => version.id === params.versionId)?.skill
: scope.skills.get(name);
if (!skill) {
reject(respond, `Mock Workshop skill not found: ${name}`);
return;
}
respond({ name, filePath: "SKILL.md", content: skill.content, files: skill.summary.files });
},
"skills.workshop.archive": (params, agentId, respond) => {
const scope = scopeFor(agentId);
const name = typeof params.name === "string" ? params.name : "";
if (!scope.skills.has(name)) {
reject(respond, `No live Workshop skill named ${name}.`);
return;
}
const reason = typeof params.reason === "string" ? params.reason : "archived";
const change = record(scope, agentId, name, "archive", reason);
scope.skills.delete(name);
respond({ change });
},
"skills.workshop.restore": (params, agentId, respond) => {
const scope = scopeFor(agentId);
const name = typeof params.name === "string" ? params.name : "";
const versions = scope.versions.get(name) ?? [];
const version =
typeof params.versionId === "string"
? versions.find((entry) => entry.id === params.versionId)
: versions[0];
if (!version) {
reject(respond, `No saved version of ${name} to restore.`);
return;
}
const change = record(scope, agentId, name, "restore", `restored ${version.id}`);
scope.skills.set(name, structuredClone(version.skill));
respond({ change });
},
};
for (const [method, handler] of Object.entries(handlers)) {
gateway.setRequestHandler(method, ({ params, respond }) => {
const input = (params ?? {}) as Record<string, unknown>;
handler(input, typeof input.agentId === "string" ? input.agentId : "main", respond);
});
}
}

View file

@ -691,17 +691,6 @@ const reviewedOperations = new Map([
},
],
],
[
"src/agents/workspace-state-store.ts",
[
{
tier: "T2",
operations: ["retireWorkspaceRelocationAttestation"],
evidence:
"Only commands/doctor-skill-workshop-workspaces.ts:266 retires migration attestations",
},
],
],
[
"src/agents/workspace-state-store.kernel.ts",
[
@ -1719,23 +1708,6 @@ const reviewedOperations = new Map([
},
],
],
[
"src/skills/workshop/store-sqlite-record.ts",
[
{
tier: "T3",
operations: ["readStoredProposalInDatabase", "updateProposal"],
evidence:
"Doctor native read/update src/commands/doctor-skill-workshop-sqlite.ts:333,349; other callers use src/skills/workshop/store.worker.ts:68,129,149,167,182,188.",
},
{
tier: "W",
operations: ["insertProposal"],
evidence:
"Only src/skills/workshop/store-proposal.kernel.ts:73,168 inserts; sole executors store.worker.ts:139,157.",
},
],
],
[
"src/state/agent-deletion-journal.read.ts",
[
@ -2066,13 +2038,6 @@ const workerModules = new Set([
"src/skills/lifecycle/upload-store.kernel.ts", // Skill-upload worker dispatcher only.
"src/skills/lifecycle/upload-store.sqlite.ts", // Skill-upload worker kernels; host imports pure options only.
"src/skills/workshop/collection-review.kernel.ts", // Skill-workshop worker collection-review reads only.
"src/skills/workshop/curator.kernel.ts", // Skill-workshop worker curator and usage commands only.
"src/skills/workshop/store-proposal.kernel.ts", // Skill-workshop worker proposal commands only.
"src/skills/workshop/store-sqlite-event.ts", // Skill-workshop and shared-state Doctor worker commands only.
"src/skills/workshop/store-sqlite-rollback.ts", // Skill-workshop worker rollback commands only.
"src/skills/workshop/store-sqlite-transition.ts", // Skill-workshop worker transition commands only.
"src/state/backup-run-records.kernel.ts", // Backup record writes are called only by the shared-state worker runtime.
"src/state/github-personal-publication-lifecycle.ts", // Receipt SQL runs in shared-state worker dispatch; host helper enqueues commands.
"src/state/openclaw-state-lease-worker.ts", // Lease transaction dispatch is called only by the shared-state worker backend.

View file

@ -667,25 +667,15 @@ function assertRuntime(artifacts, gatewayLog) {
readJson(path.join(artifacts, "dreaming-cron-runtime-converged.json")),
"Settled plugin reload rewrote an already-converged dreaming job",
);
// Gateway retains a disabled Workshop monitor even when autonomous work is off.
const workshop = current.rows.filter(
(row) =>
row.store_key === runtime.storeKey && row.declaration_key === "skill-collection-review:main",
// The weekly Workshop curator is retired; the Gateway must not recreate its rows.
assert.equal(
current.rows.filter((row) => row.declaration_key?.startsWith("skill-collection-review:"))
.length,
0,
"Runtime recreated a retired Workshop curator row",
);
assert.equal(workshop.length, 1);
assert.equal(workshop[0].enabled, 0);
const workshopJob = JSON.parse(workshop[0].job_json);
assert.equal(workshopJob.name, "skill-collection-review-main");
assert.equal(workshopJob.agentId, "main");
assert.equal(workshopJob.enabled, false);
assert.equal(workshopJob.payload.kind, "agentTurn");
assert.equal(workshopJob.sessionTarget, "isolated");
assert.equal(workshopJob.delivery.mode, "none");
const workshopState = JSON.parse(workshop[0].state_json);
assert.equal(workshopState.nextRunAtMs, undefined);
assert.equal(workshopState.lastRunAtMs, undefined);
assert.deepEqual(
current.rows.filter((row) => row !== workshop[0]).map((row) => [row.store_key, row.job_id]),
current.rows.map((row) => [row.store_key, row.job_id]),
runtime.before.rows.map((row) => [row.store_key, row.job_id]),
"Runtime changed cron row membership or order",
);

View file

@ -2478,7 +2478,7 @@ if [ "$SCENARIO" = "workshop-doctor-recovery" ]; then
phase assert-workshop-candidate-repair node scripts/e2e/lib/upgrade-survivor/workshop-doctor-recovery.mjs doctor "$workshop_doctor_observation_root" candidate
phase assert-workshop-recovery node scripts/e2e/lib/upgrade-survivor/workshop-doctor-recovery.mjs complete
run_completed="1"
echo "Workshop Doctor recovery passed: published updater refused unchanged malformed state; explicit baseline Doctor, recovered upgrade, and explicit candidate Doctor succeeded."
echo "Workshop Doctor recovery passed: published updater refused unchanged malformed state; explicit baseline Doctor repaired it; the recovered upgrade exported pending proposal drafts and retired proposal storage; explicit candidate Doctor repaired and retired restored legacy state."
exit 0
fi
if [ "$SCENARIO" = "custom-plugin-siblings" ]; then

View file

@ -5,17 +5,29 @@ import fs from "node:fs";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import { pathToFileURL } from "node:url";
import { stripVTControlCharacters } from "node:util";
import { isMainThread } from "node:worker_threads";
import {
assertWorkshopLegacyImported,
assertWorkshopLegacyWarning,
assertWorkshopProposalsRetired,
captureWorkshopLegacyState,
RETIRED_WORKSHOP_TABLES,
seedWorkshopLegacyProposals,
} from "./workshop-legacy-proposals.mjs";
const PHYSICAL_INDEX = "idx_audit_events_kind_sequence";
const INDEX = "idx_skill_workshop_collection_reviews_workspace_time";
const INDEX_SQL = `CREATE INDEX ${INDEX} ON skill_workshop_collection_reviews(workspace_dir, create_time DESC, review_id DESC)`;
// Published 2026.9.4 DDL for the retired table that carries the malformed index.
const COLLECTION_REVIEWS_SQL = `CREATE TABLE skill_workshop_collection_reviews (
review_id TEXT NOT NULL PRIMARY KEY,
owner_agent_id TEXT NOT NULL,
backup_id TEXT NOT NULL,
create_time INTEGER NOT NULL,
kept_names_json TEXT NOT NULL,
written_names_json TEXT NOT NULL,
dropped_json TEXT NOT NULL
) STRICT`;
const RETIREMENT_OUTPUT = /Skill Workshop proposal/u;
const REVIEW = {
review_id: "survivor-workshop-review",
owner_agent_id: "main",
@ -87,13 +99,16 @@ function hasMalformedWorkshopIndex(filename) {
database.prepare("SELECT review_id FROM skill_workshop_collection_reviews LIMIT 1").get();
return false;
} catch (error) {
if (
error instanceof Error &&
error.message.includes("malformed database schema") &&
error.message.includes(INDEX)
) {
if (!(error instanceof Error)) {
throw error;
}
if (error.message.includes("malformed database schema") && error.message.includes(INDEX)) {
return true;
}
// Candidate Doctor retires the table that carries the malformed index.
if (error.message.includes("no such table: skill_workshop_collection_reviews")) {
return false;
}
throw error;
} finally {
database.close();
@ -148,25 +163,18 @@ export function seedWorkshopIndex(stateDir, artifactRoot, stage) {
const filename = databasePath(stateDir);
const database = new DatabaseSync(filename);
try {
if (stage === "baseline") {
database
.prepare(
`INSERT INTO skill_workshop_collection_reviews (
review_id, owner_agent_id, backup_id, create_time, kept_names_json, written_names_json, dropped_json
) VALUES (?, ?, ?, ?, ?, ?, ?)`,
)
.run(...Object.values(REVIEW));
} else {
assert.deepEqual(
{
...database
.prepare("SELECT * FROM skill_workshop_collection_reviews WHERE review_id = ?")
.get(REVIEW.review_id),
},
REVIEW,
"Candidate reseeding must preserve the upgraded review",
);
if (stage === "candidate") {
assert.deepEqual(retiredTables(database), [], "Candidate reseeding requires retired tables");
// Restore published-build state (e.g. from a backup) onto the retired candidate schema.
database.exec(COLLECTION_REVIEWS_SQL);
}
database
.prepare(
`INSERT INTO skill_workshop_collection_reviews (
review_id, owner_agent_id, backup_id, create_time, kept_names_json, written_names_json, dropped_json
) VALUES (?, ?, ?, ?, ?, ?, ?)`,
)
.run(...Object.values(REVIEW));
database.exec(
`CREATE INDEX ${INDEX} ON skill_workshop_collection_reviews(review_id, create_time DESC);`,
);
@ -417,6 +425,15 @@ function assertPhysicalDoctorRepair(stateDir, artifactRoot, observations, logPat
physicalIndexCorruptAtStart: true,
},
);
// The first explicit candidate Doctor after the update must not touch retired Workshop state.
const legacy = readJson(path.join(artifactRoot, "workshop-legacy-seeded.json"));
assert.equal(doctor.legacyExitCaptureError, undefined, "Candidate Doctor exit capture failed");
assert.deepEqual(
doctor.legacyAtExit,
legacy.after,
"Second candidate Doctor changed retired Workshop state",
);
assert(!RETIREMENT_OUTPUT.test(normalizeOutput(log)), "Second candidate Doctor was not a no-op");
writeJson(path.join(artifactRoot, "physical-candidate-repair.json"), {
status: "explicit-candidate-physical-index-repaired",
index: PHYSICAL_INDEX,
@ -569,7 +586,21 @@ function processWitness(observations, identity, expected) {
return witness;
}
function assertRepairedState(stateDir) {
function retiredTables(database) {
return RETIRED_WORKSHOP_TABLES.filter((table) =>
database.prepare("SELECT name FROM sqlite_schema WHERE type = 'table' AND name = ?").get(table),
);
}
function normalizeOutput(text) {
return stripVTControlCharacters(text)
.split(/\r?\n/u)
.map((line) => line.replace(/^\s*[│|]\s?/u, "").replace(/\s*[│|]\s*$/u, ""))
.join(" ")
.replace(/\s+/gu, " ");
}
function assertRepairedState(stateDir, retired) {
const database = new DatabaseSync(databasePath(stateDir), { readOnly: true });
try {
assert.equal(
@ -577,15 +608,19 @@ function assertRepairedState(stateDir) {
undefined,
"Doctor left the malformed Workshop index behind",
);
assert.deepEqual(
{
...database
.prepare("SELECT * FROM skill_workshop_collection_reviews WHERE review_id = ?")
.get(REVIEW.review_id),
},
REVIEW,
"Doctor changed the retained Workshop review",
);
if (retired) {
assert.deepEqual(retiredTables(database), [], "Doctor left retired Workshop tables behind");
} else {
assert.deepEqual(
{
...database
.prepare("SELECT * FROM skill_workshop_collection_reviews WHERE review_id = ?")
.get(REVIEW.review_id),
},
REVIEW,
"Doctor changed the retained Workshop review",
);
}
assert.deepEqual(
database
.prepare("PRAGMA integrity_check")
@ -643,7 +678,7 @@ export function assertWorkshopUpdateRefusal(
export function assertWorkshopDoctorRepair(stateDir, artifactRoot, observations, stage) {
assert(["baseline", "candidate"].includes(stage));
const identity = readJson(path.join(artifactRoot, `workshop-${stage}.json`));
assertRepairedState(stateDir);
assertRepairedState(stateDir, stage === "candidate");
const doctor = processWitness(observations, identity, {
role: "doctor",
exitCode: 0,
@ -651,33 +686,24 @@ export function assertWorkshopDoctorRepair(stateDir, artifactRoot, observations,
updateInProgress: false,
});
let legacy;
let legacyWarning;
if (
stage === "candidate" &&
fs.existsSync(path.join(artifactRoot, "workshop-legacy-seeded.json"))
) {
if (stage === "candidate") {
// Doctor repairs the index before retiring the restored table; exports stay untouched.
const seeded = readJson(path.join(artifactRoot, "workshop-legacy-seeded.json"));
assert.equal(doctor.legacyExitCaptureError, undefined, "Candidate Doctor exit capture failed");
legacy = assertWorkshopLegacyImported(stateDir, seeded, doctor.legacyAtExit);
assert.deepEqual(
legacy,
readJson(path.join(artifactRoot, "workshop-recovered-upgrade.json")).legacy.after,
"Second candidate Doctor changed imported or recoverable Workshop state",
);
legacy = assertWorkshopProposalsRetired(seeded, doctor.legacyAtExit);
assert.deepEqual(
captureWorkshopLegacyState(stateDir, seeded),
legacy,
"Workshop state changed after second Doctor exit",
"Workshop state changed after candidate Doctor exit",
);
legacyWarning = assertWorkshopLegacyWarning(seeded, [
fs.readFileSync(path.join(artifactRoot, "doctor.log"), "utf8"),
]);
const log = normalizeOutput(fs.readFileSync(path.join(artifactRoot, "doctor.log"), "utf8"));
assert(
log.includes("Retired the Skill Workshop proposal tables."),
"Candidate Doctor did not retire the restored Workshop table",
);
assert(!log.includes("Exported"), "Candidate Doctor re-exported retired proposals");
}
const repair = {
status: "explicit-doctor-repaired",
doctor,
...(legacy ? { legacy, legacyWarning } : {}),
};
const repair = { status: "explicit-doctor-repaired", doctor, ...(legacy ? { legacy } : {}) };
writeJson(path.join(artifactRoot, `workshop-${stage}-doctor.json`), repair);
return repair;
}
@ -690,7 +716,7 @@ export function assertWorkshopRecoveredUpgrade(stateDir, artifactRoot, observati
candidate,
"Updater did not install the exact candidate",
);
assertRepairedState(stateDir);
assertRepairedState(stateDir, true);
const updater = processWitness(observations, baseline, {
role: "update",
exitCode: 0,
@ -706,29 +732,38 @@ export function assertWorkshopRecoveredUpgrade(stateDir, artifactRoot, observati
assert.deepEqual(
updater.legacyAtStart,
seeded.before,
"Published updater did not receive original legacy sidecars",
"Published updater did not receive the original proposal state",
);
assert.deepEqual(
doctor.legacyAtStart,
seeded.before,
"Candidate Doctor did not receive original legacy sidecars",
"Candidate Doctor did not receive the original proposal state",
);
assert.equal(doctor.legacyExitCaptureError, undefined, "Candidate Doctor exit capture failed");
const after = assertWorkshopLegacyImported(stateDir, seeded, doctor.legacyAtExit);
const after = assertWorkshopProposalsRetired(seeded, doctor.legacyAtExit);
assert.deepEqual(
captureWorkshopLegacyState(stateDir, seeded),
after,
"Workshop state changed after candidate Doctor exit",
);
const warning = assertWorkshopLegacyWarning(seeded, doctor.doctorResultAtExit?.warnings);
const legacy = { seeded, after, warning };
const upgraded = { status: "upgraded-after-explicit-repair", updater, doctor, legacy };
const warnings = doctor.doctorResultAtExit?.warnings;
assert(Array.isArray(warnings), "Missing candidate Doctor result");
assert(
!warnings.some((warning) => RETIREMENT_OUTPUT.test(warning)),
"Candidate Doctor reported a Skill Workshop proposal retirement warning",
);
const upgraded = {
status: "upgraded-after-explicit-repair",
updater,
doctor,
legacy: { seeded, after },
};
writeJson(path.join(artifactRoot, "workshop-recovered-upgrade.json"), upgraded);
return upgraded;
}
export function completeWorkshopRecovery(stateDir, artifactRoot) {
assertRepairedState(stateDir);
assertRepairedState(stateDir, true);
const firstAttempt = readJson(path.join(artifactRoot, "workshop-published-refusal.json"));
const baselineDoctor = readJson(path.join(artifactRoot, "workshop-baseline-doctor.json"));
const upgrade = readJson(path.join(artifactRoot, "workshop-recovered-upgrade.json"));
@ -741,9 +776,8 @@ export function completeWorkshopRecovery(stateDir, artifactRoot) {
assert.deepEqual(
candidateDoctor.legacy,
upgrade.legacy.after,
"Missing candidate Doctor idempotence evidence for the imported legacy proposals",
"Candidate Doctor changed exported Workshop proposals",
);
assert.equal(candidateDoctor.legacyWarning.warning, upgrade.legacy.warning.warning);
const result = { firstAttempt, baselineDoctor, upgrade, candidateDoctor };
writeJson(path.join(artifactRoot, "workshop-doctor-recovery.json"), result);
return result;

View file

@ -3,18 +3,50 @@ import { createHash } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import { stripVTControlCharacters } from "node:util";
const NOW = "2026-07-29T00:00:00.000Z";
const WORKSHOP_ROOT = "agents/main/agent/workshop-skills";
const EXPORT_ROOT = `${WORKSHOP_ROOT}/.archive/.retired-proposals`;
const PROPOSALS_DIR = "skill-workshop/proposals";
export const RETIRED_WORKSHOP_TABLES = [
"skill_workshop_collection_reviews",
"skill_workshop_proposal_events",
"skill_workshop_proposal_rollbacks",
"skill_workshop_proposals",
];
// Every proposal shape the published build can hold: SQLite rows (incl. a generation draft) and a pre-SQLite JSON bundle.
const PROPOSALS = [
{
key: "survivor-pending",
status: "pending",
json: false,
draftFile: "generations/0f5c2a8e-6d1b-4c3a-9e7f-2b8d4a6c1e90/PROPOSAL.md",
support: true,
},
{ key: "survivor-quarantined", status: "quarantined", json: false, draftFile: "PROPOSAL.md" },
{
key: "survivor-applied",
status: "applied",
json: false,
draftFile: "PROPOSAL.md",
support: true,
},
{
key: "survivor-legacy-json",
status: "pending",
json: true,
draftFile: "PROPOSAL.md",
support: true,
},
];
const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
const readJson = (filename) => JSON.parse(fs.readFileSync(filename, "utf8"));
function writeFile(filename, content) {
fs.mkdirSync(path.dirname(filename), { recursive: true });
fs.writeFileSync(filename, content, { flag: "wx", mode: 0o600 });
function databasePath(stateDir) {
return path.join(stateDir, "state", "openclaw.sqlite");
}
/** Independently seed the shipped v1 sidecar contract, without importing candidate code. */
/** Independently seed published-build proposal state, without importing candidate code. */
export function seedWorkshopLegacyProposals(stateDir, artifactRoot) {
assert.equal(
readJson(path.join(artifactRoot, "workshop-baseline-doctor.json")).status,
@ -22,267 +54,181 @@ export function seedWorkshopLegacyProposals(stateDir, artifactRoot) {
"Seed legacy proposals only after the published baseline Doctor repair",
);
const files = {};
const bundles = [
{ key: "survivor-recovered", owner: "main", recover: true },
{ key: "survivor-retained", owner: "retired-agent", recover: false },
].map(({ key, owner, recover }) => {
const id = `${key}-20260729-1234567890`;
const directory = `skill-workshop/proposals/${id}`;
const target = recover
? `workspace/skills/${key}`
: `agents/${owner}/agent/workshop-skills/${key}`;
const destination = `agents/main/agent/workshop-skills/${key}`;
const support = `Preserved ${key} support.\n`;
const header = `name: "${key}"\ndescription: "Published updater Workshop fixture"`;
const body = `# ${key}\n\nPreserve the original proposal and support bytes.\n`;
const content = `---\n${header}\nstatus: proposal\nversion: "v1"\ndate: "${NOW}"\n---\n\n${body}`;
const installed = `---\n${header}\n---\n\n${body}`;
const record = {
schema: "openclaw.skill-workshop.proposal.v1",
id,
kind: "create",
status: "pending",
title: `Create ${key}`,
description: "Published updater Workshop fixture",
createdAt: NOW,
updatedAt: NOW,
createdBy: "skill-workshop",
origin: { agentId: owner, runId: `${key}-run` },
originRunIds: [`${key}-run`],
originRunMutationCounts: { [`${key}-run`]: 1 },
proposedVersion: "v1",
draftFile: "PROPOSAL.md",
draftHash: sha256(content),
supportFiles: [
{
path: "references/proof.md",
sizeBytes: Buffer.byteLength(support),
hash: sha256(support),
},
],
target: {
skillName: key,
skillKey: key,
skillDir: path.join(stateDir, target),
skillFile: path.join(stateDir, target, "SKILL.md"),
source: "openclaw-workspace",
},
scan: { state: "clean", scannedAt: NOW, critical: 0, warn: 0, info: 0, findings: [] },
};
const rollback = recover
? {
schema: "openclaw.skill-workshop.rollback.v1",
proposalId: id,
writtenAt: NOW,
targetSkillFile: record.target.skillFile,
action: "create",
supportFiles: [{ path: "references/proof.md", existed: false }],
}
: undefined;
const contentByPath = {
[`${directory}/proposal.json`]: `${JSON.stringify(record, null, 2)}\n`,
[`${directory}/PROPOSAL.md`]: content,
[`${directory}/references/proof.md`]: support,
[`${target}/SKILL.md`]: installed,
[`${target}/references/proof.md`]: support,
...(rollback
? { [`${directory}/rollback.json`]: `${JSON.stringify(rollback, null, 2)}\n` }
: {}),
};
for (const [relative, bytes] of Object.entries(contentByPath)) {
writeFile(path.join(stateDir, relative), bytes);
files[relative] = sha256(bytes);
}
if (recover) {
for (const relative of [`${destination}/SKILL.md`, `${destination}/references/proof.md`]) {
assert.equal(fs.existsSync(path.join(stateDir, relative)), false);
files[relative] = null;
const exported = {};
const put = (relative, bytes) => {
const filename = path.join(stateDir, relative);
fs.mkdirSync(path.dirname(filename), { recursive: true });
fs.writeFileSync(filename, bytes, { flag: "wx", mode: 0o600 });
files[relative] = sha256(bytes);
};
const database = new DatabaseSync(databasePath(stateDir));
try {
database.exec("BEGIN IMMEDIATE");
for (const { key, status, json, draftFile, support } of PROPOSALS) {
const id = `${key}-20260729`;
const draft = `---\nname: "${key}"\ndescription: "Published updater Workshop fixture"\n---\n\n# ${key}\n\nPreserve the ${status} draft bytes.\n`;
const supportBytes = `Preserved ${key} support.\n`;
const bundle = path.posix.join(PROPOSALS_DIR, id, path.posix.dirname(draftFile));
const record = {
schema: "openclaw.skill-workshop.proposal.v1",
id,
kind: "create",
status,
title: `Create ${key}`,
createdAt: NOW,
updatedAt: NOW,
draftFile,
draftHash: sha256(draft),
supportFiles: support
? [
{
path: "references/proof.md",
sizeBytes: Buffer.byteLength(supportBytes),
hash: sha256(supportBytes),
},
]
: [],
target: { skillName: key },
};
put(`${bundle}/PROPOSAL.md`, draft);
if (support) {
put(`${bundle}/references/proof.md`, supportBytes);
}
if (json) {
put(`${PROPOSALS_DIR}/${id}/proposal.json`, `${JSON.stringify(record, null, 2)}\n`);
} else {
database
.prepare(
`INSERT INTO skill_workshop_proposals (
proposal_id, record_json, owner_agent_id, kind, status, created_at, updated_at, draft_hash,
applied_at, quarantined_at
) VALUES (?, ?, 'main', 'create', ?, ?, ?, ?, ?, ?)`,
)
.run(
id,
JSON.stringify(record),
status,
NOW,
NOW,
record.draftHash,
status === "applied" ? NOW : null,
status === "quarantined" ? NOW : null,
);
}
if (status === "applied") {
// Applied proposals already live as Workshop skills; retirement must leave them alone.
put(`${WORKSHOP_ROOT}/${key}/SKILL.md`, draft);
put(`${WORKSHOP_ROOT}/${key}/references/proof.md`, supportBytes);
database
.prepare(
`INSERT INTO skill_workshop_proposal_rollbacks
(proposal_id, written_at, target_skill_file, action) VALUES (?, ?, ?, 'create')`,
)
.run(id, NOW, path.join(stateDir, WORKSHOP_ROOT, key, "SKILL.md"));
database
.prepare(
`INSERT INTO skill_workshop_proposal_events
(event_id, proposal_id, proposed_version, revision_hash, event_type, occurred_at, actor_json)
VALUES (?, ?, 'v1', ?, 'applied', ?, '{"type":"system"}')`,
)
.run(`${id}-applied`, id, record.draftHash, NOW);
continue;
}
exported[`${EXPORT_ROOT}/${id}/SKILL.md`] = sha256(draft);
if (support) {
exported[`${EXPORT_ROOT}/${id}/references/proof.md`] = sha256(supportBytes);
}
}
return { record, rollback, directory, target, destination, recover };
});
const manifest = "skill-workshop/proposals.json";
const manifestBytes = `${JSON.stringify({ schema: "openclaw.skill-workshop.proposals-manifest.v1", updatedAt: NOW, proposals: [] })}\n`;
writeFile(path.join(stateDir, manifest), manifestBytes);
files[manifest] = sha256(manifestBytes);
const fixture = { bundles, files };
const before = captureWorkshopLegacyState(stateDir, fixture);
assert.deepEqual(before.files, files);
assert.deepEqual(before.database, { proposals: [], rollbacks: [], events: [] });
const retained = bundles.find((bundle) => !bundle.recover);
const retainedWarning = `Preserved Skill Workshop proposal ${path.join(stateDir, retained.directory)} for manual review: owning agent "retired-agent" is not configured; target ${retained.record.target.skillDir} (candidate agents: none). Review the retained metadata and configured workspace ownership before retrying Doctor.`;
assert(
retainedWarning.length <= 500,
"Fixture warning exceeds the Doctor IPC limit; use a shorter isolated state root",
);
const seeded = { ...fixture, before, retainedWarning };
writeFile(
database.exec("COMMIT");
} finally {
database.close();
}
put("skill-workshop/proposals.json", `${JSON.stringify({ proposals: [] })}\n`);
const fixture = {
paths: [...Object.keys(files), ...Object.keys(exported)],
before: {
files: { ...files, ...Object.fromEntries(Object.keys(exported).map((key) => [key, null])) },
exports: null,
proposalsDir: true,
tables: {
skill_workshop_collection_reviews: 1,
skill_workshop_proposal_events: 1,
skill_workshop_proposal_rollbacks: 1,
skill_workshop_proposals: 3,
},
},
after: {
files: Object.fromEntries(
Object.entries({ ...files, ...exported }).map(([relative, hash]) => [
relative,
relative.startsWith("skill-workshop/") ? null : hash,
]),
),
exports: PROPOSALS.filter(({ status }) => status !== "applied")
.map(({ key }) => `${key}-20260729`)
.toSorted(),
proposalsDir: false,
tables: {},
},
};
assert.deepEqual(captureWorkshopLegacyState(stateDir, fixture), fixture.before);
fs.writeFileSync(
path.join(artifactRoot, "workshop-legacy-seeded.json"),
`${JSON.stringify(seeded, null, 2)}\n`,
`${JSON.stringify(fixture, null, 2)}\n`,
{ flag: "wx", mode: 0o600 },
);
return seeded;
return fixture;
}
export function captureWorkshopLegacyState(stateDir, fixture) {
const files = Object.fromEntries(
Object.keys(fixture.files).map((relative) => {
fixture.paths.map((relative) => {
const filename = path.join(stateDir, relative);
if (!fs.existsSync(filename)) {
return [relative, null];
}
assert(
fs.lstatSync(filename).isFile(),
`Legacy fixture path is not a regular file: ${relative}`,
);
assert(fs.lstatSync(filename).isFile(), `Workshop path is not a regular file: ${relative}`);
return [relative, sha256(fs.readFileSync(filename))];
}),
);
const database = new DatabaseSync(path.join(stateDir, "state", "openclaw.sqlite"), {
readOnly: true,
});
const exportRoot = path.join(stateDir, EXPORT_ROOT);
const database = new DatabaseSync(databasePath(stateDir), { readOnly: true });
try {
const ids = fixture.bundles.map(({ record }) => record.id);
const rows = (table, order) =>
database
.prepare("SELECT name FROM sqlite_schema WHERE type = 'table' AND name = ?")
.get(table)
? database
.prepare(`SELECT * FROM ${table} WHERE proposal_id IN (?, ?) ORDER BY ${order}`)
.all(...ids)
.map((row) => Object.assign({}, row))
: [];
const tables = {};
for (const table of RETIRED_WORKSHOP_TABLES) {
if (
database
.prepare("SELECT name FROM sqlite_schema WHERE type = 'table' AND name = ?")
.get(table)
) {
tables[table] = Number(database.prepare(`SELECT count(*) AS n FROM ${table}`).get().n);
}
}
return {
files,
database: {
proposals: rows("skill_workshop_proposals", "proposal_id"),
rollbacks: rows("skill_workshop_proposal_rollbacks", "proposal_id"),
events: rows("skill_workshop_proposal_events", "sequence"),
},
exports: fs.existsSync(exportRoot) ? fs.readdirSync(exportRoot).toSorted() : null,
proposalsDir: fs.existsSync(path.join(stateDir, PROPOSALS_DIR)),
tables,
};
} finally {
database.close();
}
}
export function assertWorkshopLegacyImported(stateDir, fixture, current) {
assert(current, "Missing Doctor legacy exit snapshot");
const recovered = fixture.bundles.find((bundle) => bundle.recover);
const expectedFiles = { ...fixture.files, "skill-workshop/proposals.json": null };
for (const name of ["proposal.json", "rollback.json"]) {
expectedFiles[`${recovered.directory}/${name}`] = null;
}
for (const name of ["SKILL.md", "references/proof.md"]) {
expectedFiles[`${recovered.destination}/${name}`] =
fixture.files[`${recovered.target}/${name}`];
expectedFiles[`${recovered.target}/${name}`] = null;
}
/** Pending/quarantined drafts are exported, applied skills stay live, retired storage is gone. */
export function assertWorkshopProposalsRetired(fixture, current) {
assert(current, "Missing Doctor Workshop exit snapshot");
assert.deepEqual(current.tables, {}, "Doctor left retired Workshop tables behind");
assert.equal(current.proposalsDir, false, "Doctor left retired proposal files behind");
assert.deepEqual(
current.exports,
fixture.after.exports,
"Doctor must export exactly the pending and quarantined proposals",
);
assert.deepEqual(
current.files,
expectedFiles,
"Doctor changed or lost retained Workshop artifact bytes",
);
const { proposals, rollbacks, events } = current.database;
assert.equal(
proposals.length,
1,
"Candidate Doctor did not import exactly the owned legacy proposal",
);
const row = proposals[0];
const record = JSON.parse(row.record_json);
assert.match(record.appliedAt, /^\d{4}-\d{2}-\d{2}T/u);
assert.equal(record.updatedAt, record.appliedAt);
assert.deepEqual(record, {
...recovered.record,
status: "applied",
updatedAt: record.appliedAt,
appliedAt: record.appliedAt,
target: {
...recovered.record.target,
skillDir: path.join(stateDir, recovered.destination),
skillFile: path.join(stateDir, recovered.destination, "SKILL.md"),
source: "openclaw-workshop",
},
});
assert.deepEqual(
row,
{
proposal_id: record.id,
record_json: row.record_json,
owner_agent_id: "main",
kind: record.kind,
status: record.status,
created_at: record.createdAt,
updated_at: record.updatedAt,
draft_hash: record.draftHash,
origin_agent_id: record.origin?.agentId ?? null,
origin_session_key: record.origin?.sessionKey ?? null,
origin_run_id: record.origin?.runId ?? null,
origin_message_id: record.origin?.messageId ?? null,
applied_at: record.appliedAt ?? null,
rejected_at: record.rejectedAt ?? null,
quarantined_at: record.quarantinedAt ?? null,
stale_at: record.staleAt ?? null,
status_reason: record.statusReason ?? null,
},
"Authoritative Workshop proposal columns disagree with the recovered record",
);
assert.equal(rollbacks.length, 1, "Imported rollback metadata was lost or duplicated");
assert.deepEqual(
{ ...rollbacks[0] },
{
proposal_id: recovered.record.id,
written_at: recovered.rollback.writtenAt,
target_skill_file: recovered.rollback.targetSkillFile,
action: "create",
previous_content_hash: null,
previous_content: null,
support_files_json: JSON.stringify(recovered.rollback.supportFiles),
},
"Imported rollback payload changed",
);
assert.equal(events.length, 1, "Recovery must record exactly one applied event");
const event = events[0];
assert.equal(event.proposal_id, recovered.record.id);
assert.equal(event.event_type, "applied");
assert.equal(event.proposed_version, "v1");
assert.equal(event.occurred_at, record.appliedAt);
assert.deepEqual(JSON.parse(event.actor_json), { type: "system" });
assert.deepEqual(
JSON.parse(event.payload_json),
[1, { recovered: true }, null],
"Recovered Workshop event payload changed",
);
assert.equal(
event.revision_hash,
sha256(
JSON.stringify({
proposedVersion: "v1",
contentSha256: record.draftHash,
supportFiles: record.supportFiles.map((file) => ({
path: file.path,
sha256: file.hash,
sizeBytes: file.sizeBytes,
})),
}),
),
fixture.after.files,
"Doctor changed exported drafts or live Workshop skill bytes",
);
return current;
}
export function assertWorkshopLegacyWarning(fixture, outputs) {
assert(
Array.isArray(outputs) && outputs.every((output) => typeof output === "string"),
"Missing Doctor warning output",
);
const normalize = (text) =>
stripVTControlCharacters(text)
.split(/\r?\n/u)
.map((line) => line.replace(/^\s*[│|]\s?/u, "").replace(/\s*[│|]\s*$/u, ""))
.join(" ")
.replace(/\s+/gu, " ")
.trim();
const expected = normalize(fixture.retainedWarning);
const observed = outputs.find((output) => normalize(output).includes(expected));
assert(observed !== undefined, "Missing complete recoverable Workshop manual-review warning");
return { warning: fixture.retainedWarning, outputSha256: sha256(observed) };
}

View file

@ -787,7 +787,6 @@ const STORAGE_MODULE_WORK_SECONDS = new Map<string, number>([
["src/channels/message-access/operator-authority.test.ts", 31.775],
["src/agents/subagents/registry/subagent-registry.persistence.test.ts", 23.639],
["src/auto-reply/reply/session.acp-reset-routing.test.ts", 8.938],
["src/agents/tools/skill-workshop-tool.support-paths.test.ts", 1.686],
["src/claws/package-update.test.ts", 4.164],
["src/cli/update-cli.git-service.test.ts", 24.578],
["src/flows/doctor-health.fleet-preflight.test.ts", 8.648],

View file

@ -558,13 +558,6 @@ const policyTestWatches: readonly PolicyTestWatch[] = [
"src/state/openclaw-state.worker.ts",
],
},
...[
"src/commands/doctor-skill-workshop-relocation.reservations.test.ts",
"src/commands/doctor-skill-workshop-sqlite.relocation.test.ts",
].map((testFile): PolicyTestWatch => ({
testFile,
watchGlobs: ["src/skills/workshop/store.worker.ts"],
})),
{
testFile: "src/commands/models/auth.minimax-chat.test.ts",
watchGlobs: [
@ -1198,17 +1191,6 @@ const policyTestWatches: readonly PolicyTestWatch[] = [
testFile: "src/skills/library/persistence.test.ts",
watchGlobs: ["src/skills/library/persistence-child.test-support.ts"],
},
{
testFile: "src/skills/workshop/experience-review.apply.test.ts",
watchGlobs: [
"src/infra/sqlite-store.worker.ts",
"src/skills/workshop/curator.kernel.ts",
"src/skills/workshop/store-proposal.kernel.ts",
"src/skills/workshop/store.worker.ts",
"src/state/openclaw-state-worker-runtime.ts",
"src/state/openclaw-state.worker.ts",
],
},
{
testFile: "src/state/openclaw-agent-participants-migration.test.ts",
watchGlobs: ["test/fixtures/sqlite/openclaw-agent-schema-v22.sql"],
@ -1742,16 +1724,9 @@ const policyTestWatches: readonly PolicyTestWatch[] = [
watchGlobs: ["src/infra/state-migrations.snapshot.worker.ts"],
},
{
testFile: "src/infra/state-migrations.skill-workshop.test.ts",
watchGlobs: ["src/state/openclaw-state.worker.ts"],
},
...[
"src/infra/update-candidate-state.test.ts",
"src/infra/update-candidate-workspace-rehearsal.test.ts",
].map((testFile): PolicyTestWatch => ({
testFile,
testFile: "src/infra/update-candidate-state.test.ts",
watchGlobs: ["src/infra/update-candidate-state.worker.ts"],
})),
},
{
testFile: "src/state/agent-database-admission.test.ts",
watchGlobs: [

View file

@ -1441,13 +1441,6 @@ export const PR_PROTECTED_RUNTIME_TEST_FILES: readonly string[] = [
"src/agents/tools/sessions-spawn-tool.test.ts",
"src/agents/tools/sessions-tool.batch.test.ts",
"src/agents/tools/sessions.test.ts",
"src/agents/tools/skill-workshop-tool.collection-restore.test.ts",
"src/agents/tools/skill-workshop-tool.evaluation.test.ts",
"src/agents/tools/skill-workshop-tool.lifecycle.test.ts",
"src/agents/tools/skill-workshop-tool.projection.test.ts",
"src/agents/tools/skill-workshop-tool.review.test.ts",
"src/agents/tools/skill-workshop-tool.revision-constraint.test.ts",
"src/agents/tools/skill-workshop-tool.skill-identity.test.ts",
"src/agents/tools/skill-workshop-tool.test.ts",
"src/agents/tools/subagents-tool.test.ts",
"src/agents/tools/swarm-tools.integration.test.ts",
@ -1804,10 +1797,9 @@ export const PR_PROTECTED_RUNTIME_TEST_FILES: readonly string[] = [
"src/cli/secrets-store-cli.test.ts",
"src/cli/send-runtime/channel-outbound-send.test.ts",
"src/cli/skills-cli.commands.test.ts",
"src/cli/skills-cli.curator.test.ts",
"src/cli/skills-cli.sag.process.test.ts",
"src/cli/skills-cli.verify.process.test.ts",
"src/cli/skills-cli.workshop-cache.test.ts",
"src/cli/skills-cli.workshop.test.ts",
"src/cli/skills-library-cli.test.ts",
"src/cli/state-dir-gateway-check.server.test.ts",
"src/cli/update-cli.admission-ledger.test.ts",
@ -2046,7 +2038,7 @@ export const PR_PROTECTED_RUNTIME_TEST_FILES: readonly string[] = [
"src/commands/doctor-session-transcripts.incident.test.ts",
"src/commands/doctor-session-transcripts.missing-index.test.ts",
"src/commands/doctor-session-worktree-workspace.test.ts",
"src/commands/doctor-skill-workshop-workspace.test.ts",
"src/commands/doctor-skill-workshop-retire-proposals.test.ts",
"src/commands/doctor-state-migrations.test.ts",
"src/commands/doctor-update-refusal.test.ts",
"src/commands/doctor-update-rehearsal-producer.test.ts",
@ -2396,7 +2388,6 @@ export const PR_PROTECTED_RUNTIME_TEST_FILES: readonly string[] = [
"src/cron/service/timer.test.ts",
"src/cron/service/timer.timeout-watchdog.test.ts",
"src/cron/session-reaper.test.ts",
"src/cron/skill-collection-review-monitor.test.ts",
"src/cron/store.retired-review.test.ts",
"src/cron/store.test.ts",
"src/cron/store/run-receipt-store.test.ts",
@ -2544,6 +2535,7 @@ export const PR_PROTECTED_RUNTIME_TEST_FILES: readonly string[] = [
"src/gateway/mcp-http.question-authority.test.ts",
"src/gateway/mcp-http.runtime.test.ts",
"src/gateway/mcp-http.test.ts",
"src/gateway/mcp-http/mediated-exec-policy.test.ts",
"src/gateway/mention-inbox.sharing-target.test.ts",
"src/gateway/method-scopes.own-session.test.ts",
"src/gateway/models-http.test.ts",
@ -2572,7 +2564,6 @@ export const PR_PROTECTED_RUNTIME_TEST_FILES: readonly string[] = [
"src/gateway/server-close.port-reservation.test.ts",
"src/gateway/server-close.test.ts",
"src/gateway/server-connection-state.test.ts",
"src/gateway/server-cron-skill-review-jobs.test.ts",
"src/gateway/server-cron.test.ts",
"src/gateway/server-http.control-ui.test.ts",
"src/gateway/server-http.openai-sandbox.test.ts",
@ -2792,7 +2783,6 @@ export const PR_PROTECTED_RUNTIME_TEST_FILES: readonly string[] = [
"src/gateway/server-methods/sessions.reclaim.test.ts",
"src/gateway/server-methods/sessions.send-followup-status.test.ts",
"src/gateway/server-methods/sessions.send-yield-resume.test.ts",
"src/gateway/server-methods/skills.proposals.test.ts",
"src/gateway/server-methods/skills.remote.test.ts",
"src/gateway/server-methods/system-agent-approval-authority.test.ts",
"src/gateway/server-methods/system-agent-approval.test.ts",
@ -3313,7 +3303,6 @@ export const PR_PROTECTED_RUNTIME_TEST_FILES: readonly string[] = [
"src/infra/state-migrations.receipts.test.ts",
"src/infra/state-migrations.retained-deleted-agent.test.ts",
"src/infra/state-migrations.shared-auth-store.test.ts",
"src/infra/state-migrations.skill-workshop.test.ts",
"src/infra/state-migrations.test.ts",
"src/infra/state-migrations.transcript-directives.test.ts",
"src/infra/state-migrations.web-push.test.ts",
@ -3336,7 +3325,6 @@ export const PR_PROTECTED_RUNTIME_TEST_FILES: readonly string[] = [
"src/infra/update-candidate-state.online-backup.process.test.ts",
"src/infra/update-candidate-state.schema-lifetime.test.ts",
"src/infra/update-candidate-state.schema.test.ts",
"src/infra/update-candidate-workspace-rehearsal.test.ts",
"src/infra/update-failure-report-prepare.test.ts",
"src/infra/update-failure-report.test.ts",
"src/infra/update-global.bun-owner.test.ts",
@ -3577,13 +3565,8 @@ export const PR_PROTECTED_RUNTIME_TEST_FILES: readonly string[] = [
"src/skills/runtime/session-snapshot.test.ts",
"src/skills/runtime/tool-dispatch.test.ts",
"src/skills/runtime/workspace-worker.test.ts",
"src/skills/workshop/collection-restore.test.ts",
"src/skills/workshop/experience-review.apply.test.ts",
"src/skills/workshop/policy.test.ts",
"src/skills/workshop/revision-atomicity.test.ts",
"src/skills/workshop/service-evaluation.test.ts",
"src/skills/workshop/service-lifecycle-hooks.test.ts",
"src/skills/workshop/service.test.ts",
"src/skills/workshop/experience-review-scheduler.test.ts",
"src/skills/workshop/library.test.ts",
"src/snapshot/git-backup-streaming.test.ts",
"src/snapshot/git-backup.test.ts",
"src/snapshot/local-repository.test.ts",
@ -4373,7 +4356,6 @@ export const PR_PROTECTED_RUNTIME_TEST_FILES: readonly string[] = [
"ui/src/e2e/sidebar-roster.e2e.test.ts",
"ui/src/e2e/sidebar-session-stability.e2e.test.ts",
"ui/src/e2e/sidebar-settings.e2e.test.ts",
"ui/src/e2e/skill-workshop-collection.e2e.test.ts",
"ui/src/e2e/skills-library.e2e.test.ts",
"ui/src/e2e/subagent-task-notice.e2e.test.ts",
"ui/src/e2e/systems-workspace.e2e.test.ts",
@ -4869,11 +4851,6 @@ const PR_EXEMPT_RUNTIME_TEST_FILES = [
"src/commands/doctor-config-health-freshness.test.ts",
"src/commands/doctor-lint.native-capture.test.ts",
"src/commands/doctor-session-transcript-labels.test.ts",
"src/commands/doctor-skill-workshop-collection-backups.test.ts",
"src/commands/doctor-skill-workshop-relocation.atomicity.test.ts",
"src/commands/doctor-skill-workshop-relocation.reservations.test.ts",
"src/commands/doctor-skill-workshop-sqlite.relocation-conflicts.test.ts",
"src/commands/doctor-skill-workshop-sqlite.relocation.test.ts",
"src/commands/doctor-state-integrity.transcripts.test.ts",
"src/commands/onboard-agent.persistence.test.ts",
"src/commands/onboard-config-provenance.integration.test.ts",
@ -5154,7 +5131,6 @@ const PR_EXEMPT_RUNTIME_TEST_FILES = [
"src/skills/library/resource-read.test.ts",
"src/skills/lifecycle/upload-store-lifecycle.test.ts",
"src/skills/runtime/refresh.symbolic-source.integration.test.ts",
"src/skills/workshop/store.test.ts",
"src/state/agent-database-admission.test.ts",
"src/state/agent-deletion-journal-initialization.test.ts",
"src/state/onboarding-recommendations.test.ts",
@ -5429,7 +5405,6 @@ const PR_EXEMPT_RUNTIME_TEST_FILES = [
"ui/src/pages/plugins/plugins-catalog.e2e.test.ts",
"ui/src/pages/plugins/plugins-lifecycle.e2e.test.ts",
"ui/src/pages/profile/model-accounts.test.ts",
"ui/src/pages/skill-workshop/revision-admission.e2e.test.ts",
"ui/src/pages/usage/usage-page-detail-identity.test.ts",
"ui/src/pages/usage/usage-page-query-ownership.test.ts",
"ui/src/pages/usage/usage-page.test.ts",

View file

@ -321,7 +321,6 @@
"src/skills/lifecycle/gh-config-discovery.test.ts": "889782c1738b129d442e2d49901a8cc9171840e392421b937ad70907e2e789d3",
"src/skills/lifecycle/install-output.test.ts": "2a55cfedc31aa2470f3c7d2a743def176b37bc2f17ba705c35be740e35409775",
"src/skills/workshop/config.test.ts": "7ff46a2786f1928f5c53c80cd9951377d0e7373d2f53d73e45d8c54c4f2ad254",
"src/skills/workshop/learn-prompt.test.ts": "16f6bb26dba951b0d479c1499ef0a9a3cb60b3e6239a870a6523677966755e27",
"src/utils/chunk-items.test.ts": "4b165806a3c07eb758b3b78da6ad7d77427db9781fb7d4c9d2cf725a192f9a88",
"src/utils/normalize-secret-input.test.ts": "2b474185847ecfca939b16f82f380007fc276c640bc7b5d5f8d300e4c7e4499a",
"src/utils/reaction-level.test.ts": "19e5367b157e9875742dc851f4c69adc73dec6fbcc38b920ffc711073d8f2236",

View file

@ -2127,10 +2127,6 @@ export const UI_E2E_OWNER_WATCHES: readonly UiE2eOwnerWatch[] = [
["chat", "config"],
sessionMenuOwnerRoots,
),
pageWatch("ui/src/e2e/skill-workshop-revision-integrity.e2e.test.ts", [
"skill-workshop",
"skills",
]),
pageWatch(
"ui/src/e2e/skills-any-bin-requirements.e2e.test.ts",
["skills"],

View file

@ -199,7 +199,8 @@ export function readPluginSdkSurfaceBudgets(env: NodeJS.ProcessEnv = process.env
// +4: executor controller, binding, context, and resolver.
// +1: required session cleanup failure preserves native ownership before host reset.
// +2: approved async upstream-link writes with released sync compatibility.
3649,
// -8: retired Skill Workshop proposal hook types.
3641,
env,
),
publicFunctionExports: readPluginSdkSurfaceBudgetEnv(

View file

@ -1245,6 +1245,7 @@ src/plugins/compat/registry-records.ts
src/plugins/compat/registry.ts
src/plugins/compat/reply-tool-authority-record.ts
src/plugins/compat/session-persistence-records.ts
src/plugins/compat/skill-proposal-hooks-record.ts
src/plugins/compat/tts-preferences-record.ts
src/plugins/compat/watched-sessions.ts
src/plugins/compat/workspace-mutation-guard.ts

View file

@ -87,7 +87,6 @@ const OPTIONAL_LIVE_SHARD_FILE_ENVS = new Map([
],
["extensions/openai/realtime-quicksilver.live.test.ts", ["OPENCLAW_LIVE_GPT_LIVE"]],
["extensions/openai/realtime-talk-defaults.live.test.ts", ["OPENCLAW_LIVE_GPT_LIVE"]],
["src/skills/workshop/experience-review.live.test.ts", ["OPENCLAW_LIVE_SKILL_EXPERIENCE_REVIEW"]],
["src/system-agent/rescue-channel.live.test.ts", ["OPENCLAW_LIVE_SYSTEM_AGENT_RESCUE_CHANNEL"]],
["src/gateway/android-node.capabilities.live.test.ts", ["OPENCLAW_LIVE_ANDROID_NODE"]],
["src/gateway/gateway-acp-bind.live.test.ts", ["OPENCLAW_LIVE_ACP_BIND"]],

View file

@ -4,7 +4,6 @@
* timeout classification, and owner-provided approval outcomes.
*/
import { addTimerTimeoutGraceMs } from "@openclaw/normalization-core/number-coercion";
import { getRuntimeConfig } from "../config/config.js";
import { GatewayClientRequestError } from "../gateway/client.js";
import { sanitizeApprovalScope } from "../infra/approval-scope.js";
import { isEmbeddedMode } from "../infra/embedded-mode.js";
@ -26,7 +25,6 @@ import {
type PluginApprovalResolution,
type PluginHookBeforeToolCallResult,
} from "../plugins/types.js";
import { resolveSkillWorkshopToolApproval } from "../skills/workshop/policy.js";
import { isPlainObject } from "../utils.js";
import { resolveToolErrorDiagnostic } from "./agent-tools.before-tool-call.diagnostics.js";
import type {
@ -506,32 +504,3 @@ export async function resolveBeforeToolCallApprovalOutcome(params: {
overrideParams: overrideParamsSnapshot,
});
}
export async function resolveSkillWorkshopApprovalForFinalParams(params: {
toolName: string;
params: unknown;
approvalMode?: "request" | "report" | "deny" | "defer";
toolCallId?: string;
ctx?: HookContext;
signal?: AbortSignal;
}): Promise<HookOutcome | undefined> {
if (params.toolName !== "skill_workshop") {
return undefined;
}
const result = await resolveSkillWorkshopToolApproval({
toolName: params.toolName,
toolParams: isPlainObject(params.params) ? params.params : {},
config: params.ctx?.config ?? getRuntimeConfig(),
...(params.ctx?.agentId ? { agentId: params.ctx.agentId } : {}),
...(params.ctx?.workspaceDir ? { workspaceDir: params.ctx.workspaceDir } : {}),
});
return await resolveBeforeToolCallApprovalOutcome({
result,
approvalMode: params.approvalMode,
toolName: params.toolName,
...(params.toolCallId ? { toolCallId: params.toolCallId } : {}),
...(params.ctx ? { ctx: params.ctx } : {}),
signal: params.signal,
baseParams: params.params,
});
}

View file

@ -37,10 +37,12 @@ import { redactToolDetail } from "../logging/redact.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { getPluginToolMeta } from "../plugins/tool-metadata.js";
import { createLazyRuntimeSurface } from "../shared/lazy-runtime.js";
import type { Skill } from "../skills/loading/skill-contract.js";
import {
resolveSkillTelemetrySource,
resolveSkillTelemetrySourceValue,
} from "../skills/loading/source.js";
import { recordRunSkillUsage } from "../skills/runtime/run-usage.js";
import { resolveSkillFileHost } from "../skills/skill-file-host.js";
import type { SkillSnapshot, SkillTelemetrySource } from "../skills/types.js";
import {
@ -296,7 +298,7 @@ export function resolveToolDiagnosticIdentity(tool: AnyAgentTool): ToolDiagnosti
return { toolSource: "core" };
}
type SkillUsageMatch = {
export type SkillUsageMatch = {
skillFile?: string;
skillName: string;
skillSource: SkillTelemetrySource;
@ -312,7 +314,7 @@ function canonicalSkillFile(value: string | undefined): string | undefined {
function resolvedSkillUsageMatch(params: {
activation: SkillUsageMatch["activation"];
skill: NonNullable<SkillSnapshot["resolvedSkills"]>[number];
skill: Pick<Skill, "name" | "filePath"> & Partial<Pick<Skill, "source" | "sourceInfo">>;
}): SkillUsageMatch {
const skillFile = canonicalSkillFile(params.skill.filePath);
return {
@ -466,12 +468,23 @@ export function findSkillUsageMatch(params: {
: undefined;
}
export function emitSkillUsedDiagnostic(params: {
ctx?: HookContext;
/**
* Records one demonstrated skill use: this run's usage receipt (review trigger) and the
* trusted skill.used event (skill_usage rows, unused-skill archive clock).
*/
export function recordSkillUsed(params: {
ctx?: Pick<HookContext, "runId" | "sessionKey" | "sessionId" | "agentId" | "trace">;
match: SkillUsageMatch;
toolName: string;
toolCallId?: string;
}): void {
recordRunSkillUsage({
runId: params.ctx?.runId,
name: params.match.skillName,
source: params.match.skillSource,
activation: params.match.activation,
...(params.match.skillFile ? { skillFile: params.match.skillFile } : {}),
});
const trace = params.ctx?.trace
? freezeDiagnosticTraceContext(createChildDiagnosticTraceContext(params.ctx.trace))
: undefined;

View file

@ -1,8 +1,6 @@
import fs from "node:fs/promises";
import { expectDefined } from "@openclaw/normalization-core";
import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot } from "../config/config.js";
import { setEmbeddedMode } from "../infra/embedded-mode.js";
import {
EmbeddedPluginApprovalBroker,
@ -17,13 +15,7 @@ import {
type PluginHookBeforeToolCallResult,
} from "../plugins/types.js";
import { createDeferredCore } from "../shared/deferred.js";
import { proposeUpdateSkill } from "../skills/workshop/service.js";
import { resolveWorkshopSkillsDir } from "../skills/workshop/skills-root.js";
import { createOpenClawTestState } from "../test-utils/openclaw-test-state.js";
import {
resolveBeforeToolCallApprovalOutcome,
resolveSkillWorkshopApprovalForFinalParams,
} from "./agent-tools.before-tool-call.approval.js";
import { resolveBeforeToolCallApprovalOutcome } from "./agent-tools.before-tool-call.approval.js";
import { runBeforeToolCallHook } from "./agent-tools.before-tool-call.js";
import { callGatewayTool } from "./tools/gateway.js";
@ -43,10 +35,6 @@ vi.mock("./tools/gateway.js", () => ({
const mockGetGlobalHookRunner = vi.mocked(getGlobalHookRunner);
const mockCallGatewayTool = vi.mocked(callGatewayTool);
function pendingWorkshopConfig() {
return { skills: { workshop: { approvalPolicy: "pending" as const } } };
}
function approvalResult(
approval: Partial<NonNullable<PluginHookBeforeToolCallResult["requireApproval"]>> = {},
params?: Record<string, unknown>,
@ -112,7 +100,7 @@ function requireBeforeToolCall(
}
describe("runBeforeToolCallHook — embedded mode approvals", () => {
let hookRunner: Pick<HookRunner, "hasHooks" | "runBeforeToolCall" | "runSkillProposalChanged">;
let hookRunner: Pick<HookRunner, "hasHooks" | "runBeforeToolCall">;
let runBeforeToolCallMock: ReturnType<typeof vi.fn<HookRunner["runBeforeToolCall"]>>;
beforeEach(() => {
@ -121,7 +109,6 @@ describe("runBeforeToolCallHook — embedded mode approvals", () => {
hookRunner = {
hasHooks: vi.fn<HookRunner["hasHooks"]>().mockReturnValue(true),
runBeforeToolCall: runBeforeToolCallMock,
runSkillProposalChanged: vi.fn<HookRunner["runSkillProposalChanged"]>(),
};
mockGetGlobalHookRunner.mockReturnValue(hookRunner as HookRunner);
mockCallGatewayTool.mockReset();
@ -129,7 +116,6 @@ describe("runBeforeToolCallHook — embedded mode approvals", () => {
});
afterEach(() => {
clearRuntimeConfigSnapshot();
setEmbeddedPluginApprovalBroker(null);
setEmbeddedMode(false);
setActivePluginRegistry(createEmptyPluginRegistry());
@ -180,19 +166,13 @@ describe("runBeforeToolCallHook — embedded mode approvals", () => {
it("resolves embedded approvals through the in-process TUI broker", async () => {
const broker = embeddedBroker();
runBeforeToolCallMock.mockResolvedValue({
params: { action: "apply", proposal_id: "weather" },
});
runBeforeToolCallMock.mockResolvedValue(approvalResult({}, { path: "notes.md" }));
const resultPromise = runBeforeToolCallHook({
toolName: "skill_workshop",
params: { action: "apply", proposal_id: "weather" },
toolCallId: "call-skill-local",
ctx: {
agentId: "main",
sessionKey: "agent:main:main",
config: pendingWorkshopConfig(),
},
toolName: "demo_write",
params: { path: "notes.md" },
toolCallId: "call-demo-local",
ctx: { agentId: "main", sessionKey: "agent:main:main" },
});
await vi.waitFor(() => {
expect(broker.listPending()).toHaveLength(1);
@ -201,12 +181,12 @@ describe("runBeforeToolCallHook — embedded mode approvals", () => {
broker.listPending()[0],
"broker.listPending()[0] test invariant",
);
expect(approval?.request.toolName).toBe("skill_workshop");
expect(approval?.request.toolName).toBe("demo_write");
expect(broker.resolve(approval?.id, "allow-once")).toBe(true);
await expect(resultPromise).resolves.toEqual({
blocked: false,
params: { action: "apply", proposal_id: "weather" },
params: { path: "notes.md" },
approvalResolution: PluginApprovalResolutions.ALLOW_ONCE,
});
expect(mockCallGatewayTool).not.toHaveBeenCalled();
@ -227,9 +207,9 @@ describe("runBeforeToolCallHook — embedded mode approvals", () => {
);
const resultPromise = runBeforeToolCallHook({
toolName: "skill_workshop",
params: { action: "apply", proposal_id: "weather" },
toolCallId: "call-skill-stop",
toolName: "demo_write",
params: { path: "notes.md" },
toolCallId: "call-demo-stop",
ctx: { agentId: "main", sessionKey: "agent:main:main" },
});
await vi.waitFor(() => {
@ -357,174 +337,6 @@ describe("runBeforeToolCallHook — embedded mode approvals", () => {
expect(runBeforeToolCallMock).not.toHaveBeenCalled();
});
it("requires approval when a hook rewrites skill_workshop inspection into applying a proposal", async () => {
const params = { action: "apply", proposal_id: "weather-20260530-a1b2c3d4e5" };
runBeforeToolCallMock.mockResolvedValue({ params });
mockCallGatewayTool.mockResolvedValueOnce({
id: "skill-workshop-approval",
decision: PluginApprovalResolutions.ALLOW_ONCE,
});
const result = await runBeforeToolCallHook({
toolName: "skill_workshop",
params: { ...params, action: "inspect" },
toolCallId: "call-skill-hook-apply",
ctx: { config: pendingWorkshopConfig() },
});
expect(result).toEqual({
blocked: false,
params,
approvalResolution: PluginApprovalResolutions.ALLOW_ONCE,
});
const approvalCall = requireApprovalRequestCall("skill_workshop adjusted approval request");
expect(approvalCall.request.pluginId).toBeUndefined();
expect(approvalCall.request).toMatchObject({
title: "Apply Skill Workshop proposal",
description: "Apply a pending proposal inside your agent's Workshop directory.",
severity: "warning",
allowedDecisions: ["allow-once", "deny"],
timeoutMs: 70_000,
toolName: "skill_workshop",
toolCallId: "call-skill-hook-apply",
});
expect(approvalCall.timeoutParams.timeoutMs).toBe(80_000);
expect(runBeforeToolCallMock).toHaveBeenCalledOnce();
});
it("does not expose another agent's proposal metadata in final approval", async () => {
const testState = await createOpenClawTestState({
layout: "state-only",
prefix: "openclaw-agent-approval-scope-",
});
try {
const config = {
skills: { workshop: { approvalPolicy: "pending" as const } },
};
const skillsRoot = resolveWorkshopSkillsDir(config, "agent-a", testState.env);
const skillDir = `${skillsRoot}/agent-a-private-procedure`;
await fs.mkdir(skillDir, { recursive: true });
await fs.writeFile(
`${skillDir}/SKILL.md`,
"---\nname: agent-a-private-procedure\ndescription: Agent A private description\n---\n\n# Agent A private body\n",
"utf8",
);
const proposal = await proposeUpdateSkill({
config,
agentId: "agent-a",
workspaceDir: testState.workspaceDir,
env: testState.env,
skillName: "agent-a-private-procedure",
description: "Agent A private description",
content: "# Agent A private body\n",
});
mockCallGatewayTool.mockResolvedValueOnce({
id: "agent-b-approval",
decision: PluginApprovalResolutions.ALLOW_ONCE,
});
const result = await resolveSkillWorkshopApprovalForFinalParams({
toolName: "skill_workshop",
params: { action: "apply", proposal_id: proposal.record.id },
toolCallId: "call-agent-b-apply",
ctx: {
agentId: "agent-b",
workspaceDir: testState.workspaceDir,
config,
},
});
expect(result).toMatchObject({
blocked: false,
approvalResolution: PluginApprovalResolutions.ALLOW_ONCE,
});
const approvalCall = requireApprovalRequestCall(
"agent-b final skill_workshop approval request",
);
expect(approvalCall.request.description).toBe(
"Apply a pending proposal inside your agent's Workshop directory.",
);
expect(approvalCall.request.description).not.toContain("Agent A private");
} finally {
await testState.cleanup();
}
});
it("returns an actionable pending outcome when skill_workshop approval expires", async () => {
mockCallGatewayTool.mockResolvedValueOnce({
id: "skill-workshop-timeout",
status: "accepted",
});
mockCallGatewayTool.mockResolvedValueOnce({
id: "skill-workshop-timeout",
decision: null,
});
const result = await runBeforeToolCallHook({
toolName: "skill_workshop",
params: { action: "apply", proposal_id: "weather-20260530-a1b2c3d4e5" },
toolCallId: "call-skill-timeout",
ctx: {
agentId: "main",
sessionKey: "main",
config: pendingWorkshopConfig(),
},
});
expect(result).toMatchObject({
blocked: true,
kind: "veto",
deniedReason: "plugin-approval",
reason:
"The Skill Workshop approval request expired without a decision. This lifecycle call left the proposal unchanged and pending; check its current status in case another operator acted on it. Decide in the Skill Workshop UI or run `openclaw skills workshop apply|reject|quarantine <id>`. Do not retry this tool call in a loop.",
});
});
it("runs trusted policies before skill_workshop lifecycle approval", async () => {
trustedPolicy({ block: true, blockReason: "trusted policy blocked skill workshop" });
(hookRunner.hasHooks as ReturnType<typeof vi.fn>).mockReturnValue(false);
const result = await runBeforeToolCallHook({
toolName: "skill_workshop",
params: { action: "apply", proposal_id: "weather-20260530-a1b2c3d4e5" },
toolCallId: "call-skill-apply",
ctx: {
config: pendingWorkshopConfig(),
},
});
expect(result).toEqual({
blocked: true,
kind: "veto",
deniedReason: "plugin-before-tool-call",
reason: "trusted policy blocked skill workshop",
params: { action: "apply", proposal_id: "weather-20260530-a1b2c3d4e5" },
});
expect(mockCallGatewayTool).not.toHaveBeenCalled();
expect(runBeforeToolCallMock).not.toHaveBeenCalled();
});
it("uses runtime config for skill_workshop pending mode when hook context config is absent", async () => {
(hookRunner.hasHooks as ReturnType<typeof vi.fn>).mockReturnValue(false);
setRuntimeConfigSnapshot(pendingWorkshopConfig());
mockCallGatewayTool.mockResolvedValueOnce({
id: "skill-workshop-runtime-approval",
decision: PluginApprovalResolutions.ALLOW_ONCE,
});
const result = await runBeforeToolCallHook({
toolName: "skill_workshop",
params: { action: "apply", proposal_id: "weather-20260530-a1b2c3d4e5" },
ctx: { agentId: "main", sessionKey: "main" },
});
expect(result).toEqual({
blocked: false,
params: { action: "apply", proposal_id: "weather-20260530-a1b2c3d4e5" },
approvalResolution: PluginApprovalResolutions.ALLOW_ONCE,
});
expect(mockCallGatewayTool).toHaveBeenCalledTimes(1);
expect(runBeforeToolCallMock).not.toHaveBeenCalled();
});
it("preserves trusted policy params when before_tool_call hooks leave params unchanged", async () => {
trustedPolicy({ params: { command: "patched" } });
runBeforeToolCallMock.mockResolvedValue(undefined);

View file

@ -6,7 +6,6 @@
* remain in this sequence.
*/
import type { ToolLoopWarning } from "@openclaw/agent-core";
import { getRuntimeConfig } from "../config/config.js";
import { freezeDiagnosticTraceContext } from "../infra/diagnostic-trace-context.js";
import { getGlobalHookRunnerRegistry } from "../plugins/hook-runner-global-state.js";
import { getGlobalHookRunner } from "../plugins/hook-runner-global.js";
@ -21,7 +20,6 @@ import type {
PluginHookToolInputKind,
PluginHookToolKind,
} from "../plugins/types.js";
import { resolveSkillWorkshopToolApproval } from "../skills/workshop/policy.js";
import {
checkClientVoiceToolConfirmationPolicy,
consumeClientVoiceToolConfirmationPolicy,
@ -34,7 +32,6 @@ import { isPlainObject } from "../utils.js";
import {
mergeParamsWithApprovalOverrides,
resolveBeforeToolCallApprovalOutcome,
resolveSkillWorkshopApprovalForFinalParams,
} from "./agent-tools.before-tool-call.approval.js";
import {
beforeToolCallLog as log,
@ -177,16 +174,6 @@ export async function runBeforeToolCallHook(args: {
const policyRegistry = getGlobalHookRunnerRegistry() ?? undefined;
const shouldRunTrustedPolicies = hasTrustedToolPolicies(policyRegistry);
const normalizedParams = isPlainObject(params) ? params : {};
const initialCorePolicyResult =
toolName === "skill_workshop"
? await resolveSkillWorkshopToolApproval({
toolName,
toolParams: normalizedParams,
config: args.ctx?.config ?? getRuntimeConfig(),
...(args.ctx?.workspaceDir ? { workspaceDir: args.ctx.workspaceDir } : {}),
...(args.ctx?.agentId ? { agentId: args.ctx.agentId } : {}),
})
: undefined;
const voiceRun = resolveClientVoiceRunBinding(args.ctx?.runId);
// Nested catalog calls are gated individually; the script wrapper is not itself an action.
const voiceConfirmation = isCodeModeExecToolKind(args.toolKind)
@ -209,7 +196,7 @@ export async function runBeforeToolCallHook(args: {
params,
};
}
if (!initialCorePolicyResult && !shouldRunTrustedPolicies && !hasBeforeToolCallHooks) {
if (!shouldRunTrustedPolicies && !hasBeforeToolCallHooks) {
return withLoopWarning({ blocked: false, params });
}
const deriveOptions =
@ -405,17 +392,6 @@ export async function runBeforeToolCallHook(args: {
});
}
}
const finalApprovalOutcome = await resolveSkillWorkshopApprovalForFinalParams({
toolName,
params: finalParams,
approvalMode: args.approvalMode,
...(args.toolCallId ? { toolCallId: args.toolCallId } : {}),
...(args.ctx ? { ctx: args.ctx } : {}),
signal: args.signal,
});
if (finalApprovalOutcome) {
return withLoopWarning(finalApprovalOutcome);
}
const allowed: HookOutcome = {
blocked: false as const,
params: finalParams,

View file

@ -14,7 +14,6 @@ import {
} from "../infra/diagnostic-trace-context.js";
import { pruneMapToMaxSize } from "../infra/map-size.js";
import { getPluginToolMeta } from "../plugins/tool-metadata.js";
import { recordRunSkillUsage } from "../skills/runtime/run-usage.js";
import { copyBeforeToolCallWrapperMetadata } from "./agent-tool-metadata.js";
import {
captureAgentToolExecutionBudget,
@ -27,12 +26,12 @@ import {
} from "./agent-tools.before-tool-call.decision.js";
import {
buildToolContentPrivateData,
emitSkillUsedDiagnostic,
emitToolBlockedSecurityEvent,
findSkillUsageMatch,
prepareToolTerminalPresentation,
reconcileLoopCallExecutionParams,
recordLoopOutcome,
recordSkillUsed,
rememberPendingTerminalPresentation,
resolveToolDiagnosticIdentity,
resolveToolErrorDiagnostic,
@ -549,19 +548,7 @@ export function wrapToolWithBeforeToolCallHook(
ctx,
});
if (skillMatch && terminalDiagnostic.type === "tool.execution.completed") {
recordRunSkillUsage({
runId: ctx?.runId,
name: skillMatch.skillName,
source: skillMatch.skillSource,
activation: skillMatch.activation,
...(skillMatch.skillFile ? { skillFile: skillMatch.skillFile } : {}),
});
emitSkillUsedDiagnostic({
ctx,
match: skillMatch,
toolName: normalizedToolName,
toolCallId,
});
recordSkillUsed({ ctx, match: skillMatch, toolName: normalizedToolName, toolCallId });
}
if (hookOptions.emitDiagnostics) {
emitTrustedDiagnosticEventWithPrivateData(

View file

@ -202,7 +202,6 @@ describe("resolveCliBackendConfig", () => {
config: { command: "setup-acme", args: ["run"] },
parseJsonlEvent,
resolveModelId,
isolatesInstructionsWithExactTools: true,
});
cliBackendsTesting.setDepsForTest({
resolveRuntimeCliBackends: () => [],
@ -218,7 +217,6 @@ describe("resolveCliBackendConfig", () => {
expect(resolved.resolveModelId?.({ modelId: "acme-large", contextWindow: "1m" })).toBe(
"acme-large[1m]",
);
expect(resolved.isolatesInstructionsWithExactTools).toBe(true);
});
it("returns null when no plugin owns the backend", () => {
@ -251,7 +249,6 @@ describe("resolveCliBackendConfig", () => {
manualCompaction,
nativeToolMode: "selectable",
toolAvailabilityEnforcement: "execution-args",
isolatesInstructionsWithExactTools: true,
sideQuestionToolMode: "disabled",
}),
],
@ -266,7 +263,6 @@ describe("resolveCliBackendConfig", () => {
expect(resolved.manualCompaction).toBe(manualCompaction);
expect(resolved.nativeToolMode).toBe("selectable");
expect(resolved.toolAvailabilityEnforcement).toBe("execution-args");
expect(resolved.isolatesInstructionsWithExactTools).toBe(true);
expect(resolved.sideQuestionToolMode).toBe("disabled");
});
@ -283,7 +279,6 @@ describe("resolveCliBackendConfig", () => {
});
expect(requireBackend().toolAvailabilityEnforcement).toBeUndefined();
expect(requireBackend().isolatesInstructionsWithExactTools).toBeUndefined();
});
});

View file

@ -30,7 +30,6 @@ export type ResolvedCliBackend = Pick<
| "parseJsonlEvent"
| "parseJsonlLifecycleEvent"
| "toolAvailabilityEnforcement"
| "isolatesInstructionsWithExactTools"
| "projectNativeToolAuthority"
| "nativeToolMode"
| "hostOwnedTools"
@ -264,7 +263,6 @@ export function resolveCliBackendConfig(
parseJsonlEvent: backend.parseJsonlEvent,
parseJsonlLifecycleEvent: backend.parseJsonlLifecycleEvent,
toolAvailabilityEnforcement: backend.toolAvailabilityEnforcement,
isolatesInstructionsWithExactTools: backend.isolatesInstructionsWithExactTools,
projectNativeToolAuthority: backend.projectNativeToolAuthority,
nativeToolMode: backend.nativeToolMode,
hostOwnedTools: backend.hostOwnedTools,

Some files were not shown because too many files have changed in this diff Show more