Files
tim-inkeep a3f739c5d9 feat(open-knowledge): skills marketplace, in-place skills, cross-harness plugin detection (#2923)
* fix(ok): install add materializes a stale alias so the editor really gets the skill

The add-side mirror of the remove unfollow remedy: adding an editor whose
skills folder is a symlink into a pool that lacks the skill used to
silently no-op (projection never writes through an alias). The alias now
unlinks into per-skill links first, keeping everything it followed, and
the projection writes the real bundle. Pinned by an integration test on
the legacy-link repro shape.

* fix(ok): reject non-git sources from skills.sh resolution

The search response is untrusted external data; a local-path shape in its
source field would classify as a local import downstream and bypass the
git-transport allowlist. The resolver now requires a remote git source and
the transport allowlist tests cover git://+http:// positives and
ftp://+svn:// negatives. Addresses the two open PR review threads.

* fix(ok): calm two skill-surface rough edges from live testing

Drop the Skills tree's bordered three-dot chip so both trees share
Pierre's default anchor (the chip read as a glitch next to the Files
tree). And stop flagging 'changed outside' for a placement whose on-disk
form is a symlink resolving to the skill's own canonical dir — folder
link to per-skill link conversions are a healthy known state, not
foreign drift; a link pointing anywhere else still flags.

* chore: restore .agents/skills/vercel-composition-patterns dropped by Jul 13 churn

The whole skill vanished as collateral in 7c658b413 (a code commit);
restored byte-identical from main so the .agents tree carries no diff.

* docs(ok): record adopt-provenance fossil on in-place skills (From Claude chip)

* chore: restore .cursor/skills/open-knowledge tracked files dropped in the merge

The local checkout had .cursor/skills as a machine-local symlink, removed
pre-merge so the merge could proceed; the merge then recorded main's
tracked bundle files as deleted. Restored byte-identical from main.

* docs(ok): adopt-provenance question resolved: no writer remains, reader is legacy

* feat(ok): plugin-cache copies update across plugin versions

An Edit-a-copy import records the exact cache dir, but the plugin
manager keeps versions as sibling dirs and prunes old ones, so honoring
the pin verbatim made Update fail exactly when there was an update.
Reimport now re-points the version segment at the newest cached version
when the skill subpath still exists there; the recorded provenance stays
the original pin. Also records two link-resolution enhancements from
live testing (inert backticked bundle paths; symlink-path relative
resolution).

* Stop tracking the OpenKnowledge project skill

The `open-knowledge` skill is regenerated per machine and version-stamped per app build, so tracking it causes recurring sync conflicts. It is now git-ignored.

* feat(ok): backticked bundle paths in skill docs are clickable (display-only)

Skills natively write references/... and scripts/... as inline code;
those chips were inert in the editor. A decoration plugin (skill docs
only) styles whole-span bundle-path code marks with a link affordance
and opens the bundle file through the scope-aware skill-file route on
click. Bytes and schema untouched; whole-span anchoring plus a
no-dot-leading segment class keep prose mentions and ../ escapes inert.
Source-mode CodeMirror equivalent is a recorded follow-up.

* fix(ok): skill path chips are quiet at rest, link affordance on hover only

* fix(ok): in-place skill bundles get their structural graph edges back

The SKILL-to-reference pairing minted the retired .ok/skills shape via
skillLiveDocName, which matches no live in-place doc, so every editor-dir
bundle silently lost its backlinks, forward links, and graph edges after
the store retirement. Pairing now derives the SKILL doc name from the
bundle doc's own root, which also keeps same-name bundles in different
editor dirs from cross-linking. Pinned with in-place-shape tests.

* fix(ok): store-fossil sweep — search, bundle-file delete, reconcile, tab close

Full audit of every .ok/skills-shaped call site after the same fossil
class broke the structural graph edges. Search enumerated only the
retired store, so in-place project skills were absent from keyword and
semantic search entirely; bundle-file DELETE resolved the raw store root
and silently no-opped for in-place skills; the scope-move reconcile
retargeted tabs to a minted store shape (phantom class); and the delete
dialog's tab close matched only that shape, leaving in-place tabs open.
All four now resolve real in-place paths, with regression pins. The
remaining store references are the documented legacy-read drain window,
recorded on the live board with one low-risk residual.

* docs(ok): close the live-confirmed + accepted decision items (Tim, Jul 24)

* feat(ok): source-mode skill path links + bundle links resolve from any bundle doc

Source mode gets the same clickable bundle-path affordance as the
WYSIWYG (shared regex + doc-target contract, decoration-only, pinned).
resolveSkillBundleWikiTarget now accepts any bundle doc as the source —
reference docs, global managed artifacts, and extskill buffers — since
bundle paths are authored bundle-root-relative everywhere. Realpath
resolution for out-of-bundle escapes is scoped out deliberately.

* feat(ok): rename/move skill bundle files (§8.9)

A guarded POST /api/skill-file/rename moves one bundle file inside a
skill: both paths run the write/delete allowlist + containment gate,
destinations never overwrite, the skill dir resolves in-place-first, and
a project .md reference also moves its live content-doc identity (
conflict-gated teardown, link-graph rename, open-tab retarget). Client:
Rename on the file row's menu + a path dialog where rename and
move-between-subdirs are the same edit. Registered in both route
meta-tests; integration-pinned.

* feat(ok): stash reconcile-discarded edits out-of-tree (R7 data-safety half)

A disk-wins store reconcile replaces the user's in-buffer bytes; they now
stash to ~/.ok/edit-backups/discarded/<doc>/ (bounded 5 per doc) before
the import, so keep-mine stays recoverable. The interactive prompt is
recorded as its own pass pending the CC1 channel decision.

* feat(ok): create bundle files + folders from the Skills tree

New file on the skill row and on references/scripts dir rows (seeded
with the dir path); nested paths create folders implicitly (the server
mkdirs parents). Markdown lands as a live editable doc and opens
immediately; scripts and fixtures write fs-direct. Path validity is
single-sourced in lib/skill-bundle-paths shared by the create + rename
dialogs; the server allowlist stays authoritative.

* feat(ok): find-in-file for read-only skill files (§8.4)

Cmd/Ctrl+F inside a read-only prose viewer opens an in-viewer find bar:
matches paint via the CSS Custom Highlight API (DOM untouched), with
match counts, Enter/Shift+Enter + button navigation, and Escape to
close. Scoped to the viewer surface via a window-capture listener gated
to prose files; text files keep CodeMirror's own built-in search panel.
No Electron IPC — identical behavior in web and desktop.

* docs(ok): §7 dependency-gap investigation — decision-ready recommendation

* feat(ok): bundle-file mutations admit any in-bundle path

The write/rename/delete surface now matches what import and reads always
allowed: root files and custom dirs (assets/, docs/, ...) are writable,
with SKILL.md protected (managed verbs only), .. and containment gates
unchanged, and references/scripts keeping their CRDT routing. Dialogs,
tree menus, and the shared client validity check widen in lockstep.

* fix(ok): folder rows actually show their menu (the anchor predicate still vetoed dirs)

* fix(ok): find bar covers preview surfaces + delete kills store residents

Cmd/Ctrl+F now works on the skill PREVIEW surfaces too (plugin-cache,
explore, detected) via a shared useFindInViewer hook — the capture also
fires when nothing holds focus, which is the normal state after opening
a file. And delete is TOTAL: deleting a skill whose canonical lives in a
native dir also removes a same-name legacy .ok/skills store resident,
which otherwise resurfaced as a zombie row (the reported global
frontend-design case). Both pinned.

* docs(ok): record Jul 24 eng-checkin decisions (skill references, auto-update x git, launch timing)

* feat(ok): install menus widen + link to skill Settings (eng-checkin asks)

* fix(ok): auto-update refuses git-tracked skills (launch blocker from eng checkin)

Two machines auto-updating a git-tracked bundle with autoSync on would
churn-war the lockfile (per-machine importedAt/baselineRef) and bundle
bytes on every upstream release. The reimport dry-run now reports
gitTracked via git ls-files; the auto-apply loop skips those skills and
the toggle disables with the repo-flow explanation. Manual Update stays.

* feat(ok): /skill-name references recognized in skill docs (eng-checkin decision)

Standalone /name tokens in skill-doc prose decorate in both editor
surfaces: an installed skill reads as a link and opens on click; an
unknown slug renders as a missing reference whose click routes to the
Skills hub install flow. Classification is name-set-gated (shared
module snapshot) with a stoplist so paths, urls, and /tmp-class tokens
stay inert; code spans are never decorated. Convention verified across
Claude Code, Cursor, and the cross-agent SKILL.md ecosystem. The
slash-menu 'Reference a skill' picker is the remaining half.

* feat(ok): slash menu surfaces skill references in skill docs

Typing / in a skill doc lists the skills you already have as a top
Skills group — the menu is the picker, so /gri filters straight to
grilling. Selecting inserts the plain /name convention bytes and the
reference decoration renders it live. Item sources gained an optional
editor context (backward compatible) to gate on the active doc.

* fix(ok): skill-reference picker is same-scope only

A global skill referencing a project skill breaks wherever that project
isn't open, so the / picker offers only the doc's own scope (per-scope
name maps; the merged project-wins map stays for decoration/click).

* feat(ok): explore-preview install menu matches the installed menu (Tim's 'same menu')

The Copies/Symlinks mode tabs now render in both contexts — installed
skills convert their locations; the explore/skills.sh preview chooses
how its FIRST install lands (real linkMode state threaded through the
import-once install cycle). One menu, one mental model.

* chore(ok): drop the now-unused installed destructure

* feat(ok): fork resolution — the conflict chip is a door, not a warning

Same name, different bytes in a non-canonical editor dir: clicking the
CONFLICT chip opens a diff of the two SKILL.md bodies with three verbs —
keep the source (fork aligns), use this version (the fork becomes the
source and re-projects), or keep both (the fork renames into an
independent skill, frontmatter rewritten in lock-step). Every path
stashes the discarded bytes to ~/.ok/edit-backups/forks first, and the
one-row-per-name identity invariant holds: forking is deliberate, never
an ambient collision. Rides the install route (no new endpoint);
integration-pinned for all three actions plus the not-a-fork refusal.

* chore(ok): drop redundant spread flagged by oxlint

* fix(ok): Skills group leads the slash menu; whole-span code refs decorate

Category order is explicit now (skills, then the unlabeled built-ins,
then component groups) instead of an accident of the unknown-category
fallback rank. And a whole-span inline-code /name — the common authored
form — decorates as a skill reference in both surfaces; longer code
spans stay inert.

* fix(ok-skills): native-root watcher mapping, /name graph edges, plugin provenance, menu polish

- managedArtifactDocNameForPath covers ALL global skill roots (store + native
  editor dirs): native-root watcher events were silently dropped, so an
  open-but-empty __skill__/global doc never seeded when its file landed
  (the empty edit-a-copy body) and cross-instance edits never reconciled
- /skill-name refs draw real graph/backlink edges: grammar moved to core
  (one source for editor chips + server index), recorded per bundle doc,
  resolved same-scope at read time like structural edges; global bundles
  record refs during ingest without parsing wiki/md links
- plugin provenance: preview response carries marketplaceUrl (GitHub button
  on read-only plugin previews), origin chip says 'From the X plugin'
  instead of a machine path, delete dialog explains the detected plugin
  version resurfaces after deleting a copy
- install menu: folder-level aliases fall back to scope-level union for
  un-imported previews (consistent menus), Install-on-all folds into the
  section label, Settings renamed Skills settings

* feat(ok-skills): generalize plugin detection behind a provider registry

Extracts the Claude-specific plugin-cache logic into a PluginProviderAdapter
registry (inspectPluginSource / pluginRepositoryUrl / resolvePluginUpdateSource)
so plugin provenance, marketplace links, and update-source resolution route
through one provider-neutral seam. plugin-cache.ts's two functions become thin
shims over the registry; the preview and reimport handlers consume the registry
directly. Adds the SkillProvenance / SkillInert schema shapes (plugin, marketplace,
version, commands/hooks/mcp capability flags) the cross-harness enumerator fills.

* feat(ok-skills): detect plugins in skills.sh source repos across harnesses

skills.sh never flags that a skill belongs to a plugin, but the source repo's
manifest does and the preview already clones it. inspectPluginBundleDir(dir)
recognizes the clone as a plugin via each provider's manifest, then enumerates
bundled sibling skills (shared skills/<name>/SKILL.md convention) + capability
flags (commands/hooks/mcp/agents). Adds codex/gemini/copilot detection-only
providers (verified manifest paths); OpenCode deliberately omitted (no plugin
manifest). Preview response carries pluginBundle for the disclosure UI.
setupSupported marks Claude (OK-drivable) vs disclose-only providers.

* feat(ok-skills): disclose when a skills.sh skill is part of a full plugin

The Explore preview now shows a 'Part of the <plugin> plugin' banner when the
source repo is a detected plugin bundle: names the sibling count + capability
flags (hooks/commands/MCP/subagents), and offers 'Import all N skills' — one
action that brings in the whole set from the same source. Skills import here;
executable capabilities are disclosed with a repo link, never run by OK.
Adds a banner slot to SkillBundlePreview (SKILL.md view only).

* feat(ok-skills): resolve dependent /skill-name refs by trusted provenance

A skill that references /other-skill you don't have now resolves by precedence
instead of a fuzzy name search: (1) already installed → open it (local wins, no
fork); (2) a sibling in the referencing skill's own origin repo/plugin → open
its import preview; (3) a same-publisher skills.sh result with the exact name →
same. No trusted match → the missing-ref chip drops to the Skills hub for MANUAL
search; OK never auto-picks a stranger's same-name skill. New read-only GET
/api/skills/resolve-ref (registered exempt in both route meta-tests); the
missing-ref chip calls it and navigates on the result.

* refactor(ok-skills): remove confirmed dead code from the branch audit

Applies the verified dead-code findings from the necessity audit:
- delete 4 underscore-silenced dead test helpers + writeLegacyUserSkill's
  never-varied hostDir param (skill-install.test.ts)
- delete the dead _SHIPPED_SKILL_NAME constant + fix the stale editors.ts note
- collapse resolveDefaultSkillHomeRel's identical-arm ternary
- inline defaultPlaceDir (a param-ignoring constant) at its 3 call sites
- trim unread response fields from the skills-api wrappers (skippedBinaryFiles
  + its dead toast, created ×3, unplaceSkill.removed, setSkillSource.hosts,
  revertSkill.restoredFiles) and fetchPopularSkills' fixed limit param
- remove SkillTargetsGetSuccessSchema.installMode (never set or read)
- remove the setMode install request (no production sender) + its handler
  branch + the 2 test cases
- drop the unreachable else in SkillsSidebarSection's dir context menu
- correct the ImportSkillDialog 'what happens next' copy (retired .ok/skills
  store -> the real .agents/skills destination)

Deferred to the audit-resumption pass (re-verified there): viaHosts removal,
SkillInstallMenu/skill-actions prop-required tightening, scanBase param, an
enumerate.test assertion.

* test(ok-skills): update import-disclosure assertion to the .agents/skills copy

* refactor(ok-skills): re-audit follow-ups + fix Copilot detection false-positive

PR-bot fix (real bug in new code): a bare-root plugin.json is a common filename,
so Copilot detection now requires a Copilot 'skills' discriminator on the
bare-root case; namespaced .plugin/ and .github/plugin/ manifests stay
unambiguous. Adds a regression test for the unrelated-plugin.json case.

Applied re-verified audit findings (fresh adversarial pass against HEAD):
- drop the unreachable force param + dead wait loop on flushDiskAndDetectOutcome
- remove unread API-surface fields: SkillOrigin.pluginProvider + .ref, and
  SkillReimportSuccess.ref + .previousRef (the lockfile ref, still recorded, is
  kept — only the never-read HTTP response echoes go)
- drop orphaned InPlaceSkill.viaHosts (alias info already flows via hostAliases)
  + its test assertions
- make SkillFileContextMenuItems.actions required (every caller passes it)
- delete a dead vi.doMock stub for a nonexistent documents-events export

Deferred (noted for a focused follow-up): SkillInstallMenu skill-required
tightening (v2, broad) and the scanBase roots-param refactor (changes scan-root
derivation — wants careful review, not an unsupervised batch).

* feat(ok): icon-first Files/Skills toggle, search collapsed to an icon

Redesign the sidebar Files/Skills switch to be icon-first and share one row
with search:

- Each segment is icon-only; the selected segment unfurls its label via an
  animated grid-template-columns width reveal. Collapsed segments carry a
  tooltip so the bare icon can still be named on hover.
- Search collapses to a right-aligned icon-only button (⌘K hint + label move
  into its tooltip).
- Toggle + search now live together in the sidebar chrome row (FileSidebar),
  gated on showSkillsSection; the toggle is removed from SkillsSidebarSection
  to avoid duplication. Electron drag-region / chrome-fade preserved.
- Segment icons match the search icon (size-4, default stroke, muted-foreground)
  and the selected chip is a quiet bg-foreground/5 overlay.

Implementation notes: the Tooltip wraps each item unconditionally so the DOM
node is stable and the unfurl actually animates (a conditional wrapper remounts
the node, which snaps to final state). TooltipTrigger and ToggleGroupItem both
write data-state to the same element and the tooltip's value wins via asChild,
so chip background, selected text color, and the label reveal are keyed off the
`selected` prop rather than data-[state=on]; Radix still drives selection via
aria-checked.

* fix(ok): review follow-ups — icon-only search e2e, toggle a11y, lint

Address CI + reviewer feedback on the icon-first sidebar toggle PR:

- Fix the lint failure: an earlier `biome check --write` run from packages/app
  used the wrong (per-package) config and reindented two cn() blocks in
  FileSidebar.tsx 18→16 spaces. Reformatted with the subtree-root config; the
  unrelated SidebarHeader block is restored to its original indentation.
- Rewrite sidebar-search-pill.e2e.ts for the icon-only contract (the suite still
  asserted the old labeled pill: in-button kbd/label span, svg→label→kbd DOM
  order, h-9 full-width). Now covers the icon button: aria-label name, no
  in-button label/kbd, ~28px square right-aligned, and the ⌘K hint in the hover
  tooltip. Behavioral tests (open palette, ⌘K, no-op-while-open, legacy toolbar
  gone, compositional journey, offcanvas) unchanged. Validated locally: 20/20.
- Add explicit aria-label on the collapsed (icon-only) segment so its accessible
  name is pinned rather than relying on AT traversing grid-clipped text; cleared
  when selected so the visible label is the name (reviewer suggestion).
- Add FilesSkillsToggle.dom.test.tsx (toggle click → setSkillsSidebar, the
  empty-value deselect guard, collapsed-vs-selected accessible name).
- Assert the toggle is absent when showSkillsSection is off (reviewer nit).

* fix(ok-skills): detect category-nested plugin skills + honest new-file copy

Plugin detection missed real plugins (e.g. mattpocock/skills) whose skills nest
under category folders — skills/<category>/<name>/SKILL.md, not skills/<name>/
SKILL.md. enumerateBundledSkills now walks skills/ recursively (bounded depth)
for any SKILL.md and takes the containing dir's basename, so the Explore preview
shows the 'part of a full plugin' banner + accurate sibling count. Regression
test covers the nested layout.

New-file dialog: the copy claimed 'under references/ or scripts/' but any bundle
path is allowed — corrected to say any folder works, and a live hint now names
the folder(s) a nested path will create.

* fix(ok-skills): plugin banner says Install, not Import (app vocabulary)

The whole app dropped 'imported' for 'Installed' (eng-checkin decision); the
plugin-bundle banner's button + copy still said Import. Aligned to Install.

* refactor(ok-skills): disclosure-only plugin banner, multi-select explore chips, cleanup

- plugin banner is disclosure-only (Option B): drops the 'Install all N skills'
  bulk action (a footgun that installed dozens of unreviewed skills to defaults
  and re-cloned per skill) — keeps 'part of the X plugin, N skills' + View
  plugin. The per-skill Install menu already offers a real destination picker.
- Explore topic chips are MULTI-SELECT: each toggles its word in/out of the
  search box, so several stack into one query; active chips show a filled state.
- code cleanup (deferred audit findings): scanBase drops its roots param and
  derives from knownSkillRootsFor (one root-derivation, not two); SkillInstallMenuItems.skill
  is now truthfully required (every caller passes it) + the two dead !skill
  guards removed.

* feat(ok): built-in skills update via skills.sh model, not app-bundled reclaim

Move OK's three built-in skills (open-knowledge, -discovery, -write-skill)
onto the normal skills.sh update path instead of the app-bundled force-refresh.

- Reclaim seed-if-absent: reclaimUserSkillsOnLaunch + reclaimProjectSkillsOnProjectOpen
  no longer overwrite an existing copy every launch; they only seed when absent,
  preserving the presence guarantee without churn. A pulled/shared project never
  silently changes a built-in.
- Synthesize the skills.sh origin (inkeep/openknowledge-skills) on demand for
  built-ins wherever they are listed or reimported (synthBuiltinLockEntry),
  mirroring the pack retrofit pattern. autoUpdate:false = manual updates only.
  No lockfile writes on seed and no separate migration: grandfathered installs
  get the origin for free.
- Drop the metadata.version: field from the three SKILL.md and remove the dead
  version-stamp machinery (build-skill-zip expectedSkillVersion/extractMetadataVersion,
  never invoked). buildAndOpenSkill records the server package version for the
  Cowork install-state gate, aligning read and write on one version axis.

Spec: specs/2026-07-27-builtin-skills-on-skills-sh/SPEC.md

* chore(ok): remove dead code found by branch-diff audit

Verified-unused deletions (multi-agent audit + knip/grep confirmation):
- core skills-catalog/acquire/plugin-cache.ts + test: two thin wrappers over
  claudePluginProvider.{repositoryUrl,resolveUpdateSource}, re-exported from the
  barrel with zero production callers (server uses the provider-neutral
  pluginRepositoryUrl/resolvePluginUpdateSource).
- app lib/managed-artifact-doc-name.ts projectSkillFilePath(): zero references.
- app components/skill-actions.tsx _openedScope: assigned, never read.
- server skill-reconcile.ts dirsEqual(): fully subsumed by
  sameSkillModuloFrontmatter (identical 1MB cap + logic; byte-equal implies
  same-modulo-frontmatter), so the `dirsEqual(...) ||` operand was a no-op.

* docs(ok): reconcile skills docs with the shipped in-place model

The skills docs described the retired store/Draft model. Corrected to the
actual implementation (skills are versioned in place in editor dirs; the
.ok/skills store is retired for writes):

- features/skills.mdx: rewrote to the in-place model — skills are real folders
  in editor dirs (.claude/skills, .cursor/skills, .agents hub), one canonical
  mirrored into each editor; no Draft state; removed the non-existent
  'ok skills manage' command; detected skills are versioned in place
  automatically and adopted via Import → Detected.
- reference/cli.mdx: replaced the non-existent 'ok skills manage' row with the
  real 'ok skills installed' / 'ok skills import <source>'.
- reference/mcp.mdx: install now documents the location model (add/remove/mode/
  source, no draft); import writes to the .agents/skills/<name> hub, live
  immediately; move no longer mentions Draft.
- reference/what-open-knowledge-writes.mdx: dropped the non-existent
  .ok/local/skill-management.json + 'turn on skill management'; global skills
  live in home editor dirs, not ~/.ok/skills.
- 7 workflow pack pages: pack skill lands in editor dirs, not .ok/skills.

* fix(ok): CLI repair-skills seed-if-absent, matching desktop + the built-ins-on-skills.sh spec

The built-ins-on-skills.sh change made the DESKTOP reclaim seed-if-absent but
left the CLI repair-skills sweep (run by 'ok start') force-overwriting the
built-in bundles to canonical every launch. That would clobber a manual
skills.sh update on 'ok start' — exactly what the spec's decision 3 (never
force-refresh) forbids. Bring the CLI to parity:

- installUserBundleToHostDirs + the project sweep now write ONLY when the
  target is absent; an existing copy is left untouched.
- outcomes: 'overwritten' -> 'skipped-present' (user), 'reclaimed' -> 'present'
  (project).
- version-advance gate: advance on a clean reconcile (every enabled built-in
  present, whether freshly seeded or already there, no central-write failure)
  so the version fast-path still skips next launch; per-bundle telemetry marks
  'installed' for a fresh seed vs 'skip-current' for an already-present one.
- tests updated to the seed-if-absent contract (existing skills preserved;
  write/failure/guard paths exercised via the create path).

* docs(ok): correct OkFileTree headers — main Files tree hasn't migrated yet

The OkFileTree / file-tree-shared headers claimed the main Files tree
(FileTree.tsx) renders through <OkFileTree>; it doesn't — FileTree.tsx still
mounts Pierre directly. Reworded to reflect that only the skills tree uses it
today, with the main-tree migration a pending follow-up (audit finding).

* chore(ok): strip process-metadata citations from source comments

Per the comment-discipline rule (spec/decision/slice IDs rot and don't resolve
in the public mirror), strip R12/R13/R14, §-section refs, OQ-9(b), 'marketplace
slice N', 'built-ins slice', 'eng-checkin decision', 'bug-bash §', 'locked
locations-menu design', and the 'built-ins-on-skills.sh spec' names from ~25
comment sites across server/app/desktop/cli — keeping the substantive 'why' in
every case. Also corrected the desktop skill-reclaim header (project sweep is
seed-if-absent now, not force-refresh). Exempt CommonMark/RFC §-citations and
precedent #N references are untouched. (Audit finding.)

* fix(ok): address PR review findings (skill-folder LINK, resolve-ref robustness, mirror tripwire)

- skill-folder-links: benign OS/VCS dotfiles (.DS_Store, .git, …) no longer
  count as strays, so LINK isn't permanently blocked for a macOS developer who
  opened a skills dir in Finder. Other dotfiles still trip the guard. +regression test. (MAJOR)
- api-extension handleSkillsResolveRef: add the top-level try/catch every sibling
  skills handler has (readSkillsLock can throw on EACCES/TOCTOU) → named 500
  instead of a context-less outer-catch 500; and the two fallthrough catches
  (source rung, skills.sh publisher rung) now log.debug instead of swallowing
  silently, so an outage is distinguishable from a genuine miss. (MAJOR + 2 minor)
- copybara public-openknowledge-skills: add 'discovery/' to forbiddenOutputPrefixes
  (it was in include[] but had no verify_match tripwire) + regenerate the config. (MAJOR)

The bare-root Copilot plugin.json false-positive the bot also flagged was already
fixed earlier in this branch (manifestDeclaresSkills discriminator).

* fix(ok): address follow-up PR comments on the seed-if-absent change

- what-open-knowledge-writes.mdx: the repair-sweep paragraph still said skill
  bundles are 'rewritten to the current canonical form'; they're seed-if-absent
  now — split MCP-entry rewrite (still canonical) from skill-bundle seeding
  (only when missing; existing left alone, updates via skills.sh).
- repair-skills summary: surface a 'present' count so seed-if-absent no-ops
  (skipped-present) aren't invisible in the 'ok repair-skills' output.

* fix: support website-backed skills.sh installs

* docs(ok): Jul-27 skills bug-bash tracking TODO + sequencing

Execution plan for PRD-7593..7613 (bug-bash-2026-07-27): install-surface
redesign as one coordinated effort (not 6 piecemeal fixes), phased after the
data-loss/impossible-state fixes; cross-referenced against the prior-bash bugs
verified fixed this session (today's are the next layer, not reopenings).

* fix(ok): never render a blank Skills sidebar (PRD-7591)

When the Skills section is hidden by preference (showSkillsSection=false) AND a
skill surface is focused (skillFocused=true), FileSidebar rendered neither the
section nor the Files tree — a blank sidebar (also PRD-7607's 'read-only skills
have no sidebar', PRD-7605's blank case). The 'Skills view is the whole sidebar'
condition is really skillFocused AND showSkillsSection; extracted that as
skillsViewActive and used it for the file-tree gate, the empty-space context
menu, and the create toolbar — so a hidden section falls back to the Files tree
(with matching New file/folder controls) instead of blanking. +regression dom
test (skillFocused + section hidden -> Files renders, would fail on old code).

* docs(ok): mark PRD-7591 blank-sidebar fixed in the Jul-27 bug-bash TODO

* fix(ok-skills): flush live SKILL.md before install validates description (PRD-7447)

Install validates name+description off the on-disk SKILL.md, but a
description typed in the editor a moment earlier is still inside the
debounced persist window, so the disk read false-failed with
'description is missing or empty'. Force the skill's live content doc to
flush before validating; no-op for store-backed or global skills whose
docs aren't debounced. Adds a deterministic integration regression (high
persist debounce so only the handler's own flush can land the bytes).

* docs(ok): record discrete-set outcomes (7447 fixed, 7602/7603/7595 findings)

* fix(ok-skills): scope move preserves the full editor host set (PRD-7601)

A project<->global move read the source host set from the install marker
only. For an in-place skill whose editor copies exist on disk but were
never recorded in the marker (created/imported in place, older marker),
that read came back empty, so the destination lost every editor
projection and re-landed only on the default hub. Union the marker with
the scan (which editor dirs actually hold a copy) - the same authority
the install handler and sweepSkillOccurrences use - and stamp the new
marker with the destination scope. +regression (non-vacuous: neutering
the scan union drops all destination projections).

* docs(ok): record 7601 install-preservation fix + file 7614 scope-move crash

* test(ok-skills): cover scope-move host preservation through the real install path (PRD-7601)

* fix(ok-skills): scope move materializes folder-symlink audience as real installs (PRD-7601)

When editors reach a project skill only via a FOLDER-level symlink
(e.g. .claude/skills -> .agents/skills), those editors hold no
independent copy so they're excluded from the physical host set - but
they're a real audience that SEES the skill. The destination scope has
no such folder symlink, so a move silently dropped that reach. Union the
alias audience (editors whose skills-root symlinks to the skill's
canonical root, via scanHostRootAliases) into the move's host set so the
destination projects real per-editor installs and reachability survives.
+regression (non-vacuous).

* fix(ok-skills): stop minting .ok/skills store dirs — in-place always wins global reads+writes

The store was half-retired: create/move landed in-place but the live-doc
writer (managedArtifactAbsPath) DEFAULTED a global skill to
~/.ok/skills/<name> and only fell back to the native editor-dir when the
store file was absent. So a stray global-doc autosave (e.g. after the
skill was moved away) re-minted the .ok/skills remnant, and reads/edits
of any resident stayed glued to the store. Flip: native in-place wins;
the store is a read-only fallback for a not-yet-drained resident, never
the default write target. Install source resolution + the read resolver
comment aligned to in-place-wins. Existing residents drain via the boot
migration. Spec: specs/2026-07-27-retire-ok-skills-store.

* docs(ok): note Draft-state removal follows from store retirement

* fix(ok-skills): boot drain migrates store residents projected via editor symlinks

The store->in-place drain skipped any store resident whose editor dirs
held OLD-model symlink projections (.claude/skills/<name> ->
../../.ok/skills/<name>): the scan counted those symlinks as 'in-place',
so the name landed in inPlaceNames and the drain skipped the store dir as
a placement forever. Add genuineInPlaceNames() — excludes names whose
in-place canonical resolves INTO the store (a projection, not a
placement) — and pass it as the drain's skip-set at both scopes. The
migration already relocates an ok-link-projected skill correctly; it just
was never reached. +unit tests (symlink-into-store excluded; genuine real
dir included).

* test(ok-skills): modernize skill CRUD/install/restore tests to the in-place, no-Draft model

11 pre-existing failures on the branch encoded the retired store + Draft
model. Update them to the in-place reality: skills author in-place (PUT
path is the real bundle path, not .ok/skills); a skill that exists is
always installed where it lives (never a hostless Draft); the scan (not
an install marker) owns the host set, so install/uninstall/rename assert
on-disk projections instead of the marker; global skills author into the
.agents hub. Surfaced + flagged two pre-existing gaps in adjacent
subsystems (NOT store retirement): uninstall drops the wrong copy for a
hub-authored+editor-installed global skill, and an in-place skill CREATE
is not shadow-committed (only edits are). Tests assert the defensible
invariants around both.

* docs(ok): record uninstall-wrong-copy + create-not-committed gaps surfaced by retirement

* fix(ok-skills): uninstall consolidates to the .agents hub, not the editor (gap #1)

For a hub-authored + editor-installed skill, install's set-exact election
flips the canonical to the editor (.claude outranks .agents), so uninstall
was protecting the editor copy and removing the hub — the reverse of an
uninstall: the user's 'uninstall' left the skill in the editor. Uninstall
now keeps the vendor-neutral .agents hub (materializing a hub copy from the
canonical when set-exact had moved the skill into the editor, so an
uninstall never deletes the skill — no Draft) and removes every editor
projection. Never invents .agents (a hub-less skill keeps its canonical).
Move-scope is unaffected (it reaches this branch only after the source is
deleted). Regression asserts hub kept + editor projection gone.

* docs(ok): deeper diagnosis of the create-not-committed gap (commitInFlight coalescing)

* docs(ok): correct #2 diagnosis — mechanism not pinned, systemic to import, needs instrumented pass

* fix(ok-skills): create/import land in the skill's history (gap #2)

A fs-direct skill CREATE (and IMPORT) DID commit to the shadow repo — the
commit is a reachable ancestor of every later edit — but /api/history
filters contributors by the CONTENT-DOC key (<dir>/SKILL) that an edit
records via its content-doc write. The create/import attributed only the
bare dir (<dir>), so they failed the OkActor match and never appeared in
(or were restorable from) the skill's history. Attribute both under the
<dir>/SKILL content-doc key. Restore test now covers create + edit → the
created version is restorable (was: create + two edits workaround).

* docs(ok): mark gap #2 FIXED — OkActor filter key mismatch (dir vs content-doc name)

* fix(ok-skills): install menu offers only editors installable on this machine (PRD-7600)

The install-on list was the full editor vocabulary, so clicking an editor
with no folder on disk (e.g. Copilot with no ~/.copilot) flashed a
checkmark then reverted — a global install never creates a host home, so
the projection no-oped and the optimistic overlay snapped back. Surface
installableEditors per skill entry: project scope = every editor with a
project skill root (install creates the dir), global scope = only editors
whose user-home dir exists (detectUserSkillHosts). The menu (rows +
installAll) gates on it, always keeping the .agents hub and any editor the
skill is already in. +regression (global excludes copilot, project
includes it).

* fix(ok-skills): add New skill to the Cmd+K palette (PRD-7604)

The palette command registry was backfilled only from native-menu leaves
(the parity work in #2679/#2714/#2721), and New skill lives in the Skills
sidebar/hub, never a menu leaf, so the backfill structurally never picked
it up. Declare a palette-only new-skill command (no menu placement) that
creates a blank project-scope skill and opens it via a createBlankSkill
seam wired through CommandPalette to the existing useCreateBlankSkill hook.
No install on create, so it also sidesteps the 7602 immediate-install
path. +unit test on the dispatch; parity OVERRIDES updated.

* fix(ok-skills): skills-section toggle label + no switcher flash on reload (PRD-7605)

Two fixes to the "Show skills section" toggle:

1. Label. "Show skills section" read as an action that only ever shows,
   confusing when the item is checked and clicking it hides. Rename to the
   verb-less "Skills section" across all four surfaces (native menu, Cmd+K
   palette, empty-space context menu, web menubar); the checkbox check state
   already carries the current-visibility signal, so it is a clear toggle.

2. Switcher flash. On a hard reload the config CRDT is unsynced, so the
   sidebar fell back to `?? true` and mounted the Skills section (and its
   Files/Skills switcher) on first paint, then unmounted it once the config
   loaded `false`. Add a localStorage first-paint mirror of the resolved
   value (written only when config carries an explicit one, so default users
   are unaffected) so the initial render matches and there is no flash.

+regression: cache serves the prior false on an unsynced reload.

* fix(ok-skills): name prompt + default description for New skill (PRD-7602)

Two fixes to the create-skill flow:

1. Empty description blocked install. A blank create wrote
   frontmatter.description: "" — a valid draft on disk, but Install
   validates a non-empty description, so the very next Install errored
   (recurrence of PRD-7447 from a fresh create). Seed
   DEFAULT_NEW_SKILL_DESCRIPTION so a new skill is immediately installable;
   the fast paths (Cmd+K, the hub New card) take it, callers can override.

2. No chance to name it. The sidebar ... -> New skill immediately created
   new-skill with no prompt. Add NewSkillDialog (name + description), both
   fields pre-filled with the first-free name + the placeholder, so one
   click still creates a valid skill while a typed name flows through. The
   sidebar scope-row New skill now opens it; name grammar mirrors the skill
   slug and duplicates are rejected up front.

+dom tests: pre-fill/one-click create, typed values, invalid/duplicate reject.

* fix(ok-skills): rename re-projects into every editor the old name held (PRD-7603)

Renaming a multi-editor skill left a copy of the old name behind (or dropped
installs): the rename moved the canonical dir and swept the other editors,
then re-projected the new name ONLY if an install marker existed. Post
store-retirement an in-place skill often has no marker (created/imported in
place), so the re-projection was skipped and the new name landed only on the
canonical root while the old name stranded in .claude/.cursor/etc.

Capture the full host set BEFORE the sweep (marker union scan union
folder-symlink alias audience, the same rule PRD-7601 uses for scope move)
and re-project the new name into all of them unconditionally. This makes the
first rename correct, which also removes the "works only on the second try"
symptom (the second attempt was reconciling the first ones split state).

+integration repro: rename foo->bar keeps claude+cursor, old name gone
everywhere, exactly one skill listed.

* fix(ok-skills): empty description no longer hard-blocks install (PRD-7596)

Installing a skill whose SKILL.md had an empty description returned a 400,
so an already-installed in-place skill red-errored on every install click
and the optimistic checkmark reverted (the "error on every option even
though it shows installed" report). validateSkillForInstall now returns the
empty-description case as a non-blocking warning, not a blocking error:
install succeeds (200) and carries a no-description warning code. name /
frontmatter / conflict-marker / XML-tag checks stay hard errors. The client
surfaces the nudge only when actually adding to an editor, never on a no-op
or uninstall click, so repeat clicks do not nag.

Makes install authoritative: it succeeds or fails loudly, never both, and a
no-op re-install of an already-installed target is a clean success.

+unit (empty description -> warning, not error) and integration (install
200 with no-description warning; already-installed re-install clean no-op).

* fix(ok-skills): folder-Link dropdown sizes to content, not the trigger (PRD-7599)

The Skills-settings folder Link dropdown inherited the shadcn default content
width (the trigger width). The trigger is a tiny "link" icon-button, so the
menu was ~128px and clipped the mono editor-root paths (e.g. ~/.cursor/skills)
via overflow-x-hidden. Override w-auto so it sizes to the widest item.

* fix(ok-skills): in-skill .md reference links open editable, not read-only (PRD-7607)

Clicking a bundle-path link inside a skill (references/x.md) routed every
target through hashFromSkillFile -> the read-only skill-file viewer, even for
editable .md/.mdx references, while opening the same file from the sidebar
opened the editable buffer. The read-only decision came from the link path,
not the file.

Add skillBundlePathNavHash: editable .md/.mdx references of a non-built-in
skill navigate to the live editable doc (skillEntryFileLiveDocName, the same
buffer the sidebar openFile targets); scripts, binaries, and built-in
(open-knowledge*) skills keep the read-only viewer. Used by both the WYSIWYG
and source-mode click handlers so they agree.

+unit tests (md reference -> editable, script -> read-only, built-in ->
read-only).

Note: the secondary ask (give read-only skill views a sidebar) is a separate
UI change, tracked but not in this commit.

* fix(ok-skills): global skill projection uses editor USER roots, not project roots (PRD-7620)

skillHostDir/skillTargetDir always resolved EDITOR_PROJECT_SKILL_ROOT, even
for global installs. Harmless for claude/cursor/codex/opencode (project root
== user root), but Copilot (.github/skills vs .copilot/skills) and pi
(.pi/skills vs .pi/agent/skills) diverge — so a global install or a
project→global move projected Copilot/pi to ~/.github/skills / ~/.pi/skills
while the scan + install-menu detection looked at the user roots. Net effect:
Copilot/pi silently dropped at global scope.

Thread a scope-aware `roots` map (default project) through skillHostDir,
skillTargetDir, projectSkill, projectInPlaceSkill, relocateInPlaceCanonical,
removeInPlaceSkillCopies, reverseProjectSkill; every api-extension caller
passes skillProjectionRoots(scope). Defaulting to project roots keeps every
existing path byte-identical — only copilot/pi global behavior changes.

+integration: global install to copilot lands in ~/.copilot/skills (not
~/.github/skills); project→global move keeps copilot at the user root.

* fix(ok-skills): clickable reference chips in read-only skill views (PRD-7607)

A read-only skill view (built-ins, scripts, global refs) rendered its
`references/…` / `scripts/…` bundle-path chips as plain inline code with no
click handler — the read-only SkillMarkdownViewer only used sharedExtensions,
which omit the skill-path-links extension (added per-instance in the live
editor). Thread the skill identity (skillLiveDocName(scope, name)) so the
viewer loads SkillPathLinks; the chips are now clickable and route through the
shared skillBundlePathNavHash (built-ins + scripts stay read-only). ProseMirror
handleClick fires in the non-editable editor, so no edit affordance is added.

+dom test: a references/ chip in a read-only skill file gets the skill-path
decoration.

* fix(ok-skills): read-only skill views get a right Properties sidebar (PRD-7607)

Read-only skill views rendered the properties inline at the top of the scroll
(easy to miss / scroll past) rather than on the right like the editable editor,
so a read-only skill looked like it had no right sidebar. Move ReadonlyProperty
Panel into a right-side column in the standalone skill markdown viewer, matching
where the editor shows a doc's Properties. Shown only when the file has front
matter (SKILL.md always does; body-only references get none). Scoped to the
standalone viewer; the un-imported skill-preview flow (which stacks its own
properties) is untouched.

+dom tests: frontmatter file shows the Properties column; body-only does not.

Note: this is a properties-only right column, not full parity with the editable
right DocPanel (Outline/Graph/Links tabs, resizable, ⌥⌘B) — that's a follow-up.

* fix(ok-skills): clickable references in the built-in skill preview (PRD-7607)

The built-in skill preview (SkillBundlePreview via SkillPreviewTab) is the
surface in the bug-bash screenshots, and it never received the skill-path-links
wiring — its references/ chips were dead. Thread skillPathLinkDocName into the
preview so the chips are clickable, plus a new onBundlePathClick context
override on the SkillPathLinks extension: in the preview a chip SWITCHES the
selected file in place (same flavor:source:name tab, new path coordinate via
hashFromSkillPreview) instead of navigating away to a standalone viewer.
Everywhere else the default bundle-file navigation is unchanged.

Phase 1 of the read-only parity work; the resizable Properties+Outline panel is
next.

* Revert "fix(ok-skills): read-only skill views get a right Properties sidebar (PRD-7607)"

This reverts commit 917ac6452ca0846a2ff2305b3595be85e17e1a3d.

* fix(ok-skills): fork-resolution dialog never hangs on "Loading both versions" (PRD-7608)

The conflict-resolution dialog (SkillForkDialog) loads both skill versions to
diff, but the loader only set() a body on res.ok — no .catch, no error state —
and a missing absolutePath fired NEITHER fetch (early return). So any preview
failure left the dialog stuck on "Loading both versions" forever (the reported
"never loads for me"). And because the diff never rendered, the dialog
collapsed to the placeholder width, spreading the footer buttons edge-to-edge
(the reported off-centre buttons).

Fixes: the diff is a preview aid, not a gate — on a failed fetch OR a missing
absolutePath, show "Couldn't load the version preview. You can still choose how
to resolve it below." with the three resolve actions still usable. Give the
dialog a stable w-full max-w-3xl width so it holds its size (and the footer
buttons their normal right-aligned layout) even without the diff.

+dom tests: failed preview -> error + actions, not a spinner; missing
absolutePath -> immediate error, no fetch attempted.

* feat(ok): add a New file button to the open-skill toolbar (PRD-7429)

Adding a reference / script / subfolder to a skill was only reachable via the
sidebar right-click; a user editing a skill had no affordance. The write stack
(PUT /api/skill-file, writeSkillFile, SkillFileCreateDialog with nested-folder
support) already exists, so this just exposes requestFileCreate on the editor
toolbar next to the install pill.

* fix(ok): loading indicator while a big skill folder fetches (PRD-7411)

The eager per-skill bundle-file fetch (N fetches for N skills) had no loading
flag, so after an upload or on a skills-heavy folder the section sat empty/stale
with no feedback and looked broken. Add a bundleLoading flag and a subtle
section spinner shown while the list or the bundle-file fetch is in flight.
Self-scaling: a brief flash on a small KB, visible when there are many skills.

* fix(ok): install-target marks name themselves + '+N' lists locations (PRD-7606)

The agent brand marks were unidentifiable at rest and used native title=
tooltips, which don't render when the window isn't focused. Swap the cluster to
Radix Tooltip (renders regardless of focus) with a per-mark host label, and make
the '+N' overflow list the extra install locations instead of an opaque count.
Sidebar shadow-DOM row marks + in-menu row hints continue in the menu-redesign
slice.

* fix(ok-skills): install menu stops mutating existing installs (PRD-7593, PRD-7595)

The Copies/Symlinks tabs at the top of the install menu applied the chosen
mode to EVERY location the skill already occupied, so opening the menu and
tapping the mode you believed you were already in silently rewrote every
install. Both tabs are gone, along with the Custom path row and its dialog.

A new location now follows the skill's existing ones. A skill with no other
location takes the symlink default (one real folder, no duplicated bytes in
git), guarded on the server: effectiveInstallMode only applies that default
when there are zero locations besides the source, so a skill whose copies
predate the default is never reprojected behind the user. The guard covers
the MCP install verb and the post-import fan-out, not just the menu.

Mode is now a per-location choice. A row whose form diverges from the rest
of the skill carries a tag that converts THAT path, behind a confirm, via a
new convert verb on POST /api/skill/install. It is lossless only: the source
is refused, a hand-edited fork 409s, an empty location 404s. Rows that agree
with the skill lose their tag entirely, and set-source moves to a hover
control so promoting a location stays deliberate.

Three surfaces render one skill's install state at once (editor toolbar pill,
sidebar Install pill, sidebar three-dot submenu) and each mounted its own
optimistic overlay, so toggling a row in one left its neighbour reading the
stale server truth: Install next to Installed. The overlay moves to a module
store keyed scope:name, so the click lands in one place all three read.

* fix(ok-skills): install hints render when the window is unfocused (PRD-7606, PRD-7441)

Every hint in the install menu and on the sidebar rows was a native `title`
attribute, and a native tooltip does not render at all while the window is
unfocused. Click Chrome, come back, hover: nothing. That matters more here
than elsewhere because the hint is the only place several of these controls
explain themselves. The brand marks are unreadable without one, and a checked
row's hint is the only place it says that clicking removes the skill.

The menu's 11 hints now go through Radix, via a local Hint wrapper, so they
render regardless of window focus and on the app's own delay rather than the
OS's. The row, the conflict chip, the drift chip, the convert tag, the
set-source control and the audience marks all keep the same words.

The sidebar rows could not use Radix: they live inside Pierre's style-isolated
shadow root, where a React portal has no anchor. They get an imperative hint
instead, one element in the light DOM (so Tailwind styles it like the real
tooltip) positioned from the hovered cluster's rect and driven by delegated
pointer events. The cluster carries its text as a data attribute rather than
`title`, so the native tooltip cannot fire alongside it, plus an aria-label so
the marks are still announced.

* fix(ok-skills): folder symlink direction follows the row you act on (PRD-7597, PRD-7598)

Clicking Link on a folder row and picking a target ran the merge backwards:
the row you clicked became the symlink and the folder you picked survived.
The row then drew an arrow pointing at the folder you picked, so the display
said the opposite of what had happened, and the folder people believed they
had just made real was the one that had just been replaced.

Direction now follows the row: acting on .claude and picking .cursor leaves
.claude a real folder and turns .cursor into a symlink to it. The client
swaps the two arguments; the server primitive is unchanged, since it always
took "the folder that becomes the symlink" first.

The pick list no longer offers folders that are already symlinks, their own
or a parent's. There is nothing left in them to merge, so picking one could
only fail, and it was the option people kept reaching for.

The verb reads "symlink" now, in the trigger, the row's state chip, the
section blurb and the hints. It is a symlink, the rest of the app says
symlink, and it is a word you can look up.

Moving folder linking out of Settings is deferred; this is correctness in
place.

* test(ok-skills): pin the install row's checkbox contract (PRD-7594)

The rows were already checkboxes, but nothing held them there, and the
reported "clicking a row flips symlink/copy to source" came from the mode tag
that sat inside the row and moved the skill's real folder on click. That tag
is now a convert control that appears only on a location whose form diverges,
and set-source is its own labelled hover button, so a click on the row can
only install or remove.

Covers: a row reads checked/unchecked with no third state and toggles install
without touching source or mode; All is its own control; a row that agrees
with the rest of the skill carries no tag to mis-click. Renames the test file
since it now covers what a row does, not only what it says.

* feat(ok-skills): same-name, different-content skills each get their own row

Two bundles sharing a (scope, name) but holding different bytes, typically one
per editor dir, collapsed into a single row with the loser reduced to a yellow
conflict chip. The losing bundle was not a row, was not admitted as content, and
could not be opened, edited, or versioned. Its only door was a modal demanding
the user pick a winner or rename one. That hid real files on disk to protect an
invariant nothing downstream needed.

Cut 1 of the spec: rows exist.

The dedup core already grouped identity on (scope, name, contentHash), so same
name plus different bytes always produced two groups. The collapse happened
downstream, in the registry's fork-winner election and the admitted-dir filter
in the in-place scan. Both go away. Every distinct-hash group is admitted, so
each SKILL.md becomes a first-class content doc with its own history and
properties, addressed by its real path. Same-hash copies still dedup to one row,
which was always the real point of that pass.

Precedence survives for one job: picking the canonical dir among byte-identical
copies, where the choice only decides which path the movers treat as real. What
it no longer does is let a hardcoded vendor ranking decide which of two
different skills the user is allowed to see.

Sidebar tree segments gain a third disambiguation tier. The existing two tiers
fall back from display name to full name, and collided rows are identical at
both, so without the host suffix the second row was silently dropped from the
tree entirely.

Skill-file addressing takes an optional host. The moment a second row appears,
clicking its references/ file resolved through (scope, name, path) and served
the first row's bytes: silent wrong content introduced by this change, so its
fix ships with it. Omitted host still resolves to the precedence winner, and
hashes written before hosts existed still parse, so every existing caller and
every stored tab id keeps working.

Spec, including the locked decisions and the cut 2 and 3 sequencing, in
specs/2026-07-28-skill-name-collision-rows/SPEC.md.

Pre-existing on this branch and untouched here: editor-tabs.test.ts imports
projectSkillFilePath, which managed-artifact-doc-name.ts does not export, so one
isSkillDocName case fails. It fails identically with this commit reverted.

* fix(ok): post-merge lint fallout from the main merge

Drop the now-unused Trans import, sort the NavigationHistoryControls import,
and swap the share test's tooltip mock for the real TooltipProvider that
main's new lint rule requires.

* fix(ok-skills): built-in skills honor the host when reading a diverged copy

OK's own open-knowledge* bundles resolve through resolveBuiltinSkillDir, a
separate branch from authored skills, and it picked the first editor dir in
id order while ignoring the requested host. So when a built-in diverged
across host dirs, both rows served the same bundle: the .agents row listed
the .claude copy's reference files and opened its bytes.

The resolver now takes the host and probes the .agents hub alongside the
editor roots (the hub carries no editor id, so it was absent from the scan
entirely). Both read handlers thread the query param through.

Authored skills were never affected; they resolve via resolveSkillDirForRead,
which already took the host. The new test pins both paths so they can't
drift apart.

* fix(ok-skills): a gitignored skill bundle is not admitted as content

The in-place allow-list admitted a bundle dir before any gitignore check, so
a projection the user had deliberately gitignored still became a content doc.
The sync engine then tried to commit an ignored path, git refused, and the
failure classified retryable — stranding the project offline with the raw
git advice text surfaced in the sync panel.

Listing both same-name copies as rows made this fire reliably: previously
only the precedence winner was admitted, so an ignored loser stayed out by
accident. The hole was always there; it just needed the ignored copy to win.

The bundle still lists as its own row. It is readable and comparable, just
not a tracked content doc — which is what gitignoring it asked for.

* fix(ok-skills): one tooltip per row, and Skills settings opens the right scope

The install marks carried their own hint bubble on top of the row's native
tooltip, so hovering a row with marks produced two overlapping tooltips saying
different things: the marks' roots and the row's full paths. The row already
says strictly more, and every other row in the tree explains itself that way,
so the marks-only hint is gone. They keep their aria-label.

That drops the unfocused-window case the hint was added for. Consistency
across rows won that trade.

Skills settings from the install menu always opened the project pane. A global
skill's folders live under ~, which that pane does not manage, so the hop
landed somewhere that could not act on the skill it came from.

* feat(ok-skills): a way to convert every location's form, and say what that means

There was no way to make a location a copy. The per-row tag only appears on a
location whose form DIVERGES from the rest, and you cannot deliberately create
that divergence, so the verb was unreachable whenever the locations agreed.

A quiet gray action at the foot of the install list converts them all, and
states the model before writing: a copy is a real folder that OK refreshes
from the source until someone hand-edits it outside OK, at which point it
stops tracking and becomes its own skill; a symlink cannot drift but dies with
the source. It names every path it will rewrite, which is what the old
skill-wide mode tabs never did.

'Skills settings' is now 'Manage skill folders' with a hint naming the
destination — the row navigates away to folder-level wiring, one level up from
this menu, and neither the old label nor a bare gear said so.

* fix(ok-skills): OK's own bundles stay one row when a projection drifts

Two same-named bundles are two skills only when the divergence is the user's:
a copy tracks its source until someone hand-edits it outside OK, and that edit
is what makes it a thing of its own.

Built-ins have neither half. They are read-only here, so the user cannot make
the divergence, and OK ships their bytes, so a projection that no longer
matches is a stale artifact — a pre-split copy left behind by an upgrade, say.
Surfacing that as a second skill reported a stale file as if it were a choice.

Collapses to the by-name default. Authored skills are untouched: a hand-edited
copy still earns its own row, which is the whole point of the feature.

* fix(ok-skills): symlinked skills keep the Skills sidebar; harness folders can link

Following a references/ link out of a skill dropped the sidebar to Files, which
read as the Skills surface breaking. A skill dir can be a symlink to somewhere
else inside the content dir, so the same bytes index under two names; the link
resolved to the real one, whose path has no leading dot-dir and so did not read
as a skill. Widened the SURFACE check only — the parser it sits next to also
feeds the CC1 link index, where a looser shape would mint skill edges for any
document sitting at that path.

The link guard refused any folder holding a non-skill entry, which a harness's
own bookkeeping trips: Codex keeps its bundled skills in .system and leaves an
empty runtime dir behind. Neither can be cleaned up by the user and neither is
content a link could strand, so neither blocks now. A non-empty directory
without a SKILL.md still does.

Also carries main's toolbar keycap tooltips onto this sidebar's buttons, and
retires the header-layout test that asserted the project-root row this branch
replaced — the tooltip behavior it also covered is kept as its own test.

* fix(ok): opening from a tree keeps you on that tree's surface

The sidebar surface followed whatever you opened, so clicking a skill's file in
the Files tree threw you into Skills, and the reverse. The click already says
which surface you mean; deriving it from the target second-guessed that.

Both trees now pin as they open. Autofollow stays the rule for navigation that
carries no surface intent of its own — a deep link, the command palette,
session restore — which is what it was for.

The pin has to run AFTER the open: committing a new tab re-arms autofollow, so
pinning first would be overwritten.

* feat(ok-skills): explain copies vs symlinks before the click, not just in the confirm

The difference between the two forms is the thing people need explained, and it
only appeared once you had already committed to reading a confirm dialog. A '?'
beside the convert verb carries it on hover instead, without spending a line of
the menu on prose.

* test(ok): a skill's own references/ link resolves against the page cache

Pins the resolver's behavior for the shape every skill uses — a relative link
from SKILL.md into its own references/ dir.

* fix(ok-skills): folders group under the one that owns them; install menu fits its paths

The folders list drew every symlinked folder as a peer with an arrow to its
target, so the follower sat on the LEFT and the folder that actually owns the
skills sat on the right. Read naturally, that says '.claude owns these', which
is backwards. A symlinked folder is a reader of its target, so it now nests
under it; the target is the row. Each follower keeps its own unlink, which is
the verb that gives the folder back.

The earlier direction fix (PRD-7597/7598) changed which folder SURVIVES when
you act. It never changed how the list is drawn, so the misread stayed.

The install menu's rows ARE paths, and it clipped them at three different
widths because each surface picked its own. One shared floor, applied at every
call site.

* test(ok-skills): tag the divergent-host tests for the audit registry

The registry wants @covers-surface and @covers-construct per test, not once
per file.

* chore(ok): clear the audit debt this branch introduced

md-audit is green on main and red here: the branch added 47 tracker-ticket
refs in comments and 10 integration tests missing their coverage tags, so the
gate would have failed CI on this PR regardless of the push hook.

Ticket keys are stripped from comments with the behavioral substance kept, per
the comment-discipline rule — the ticket belongs in the PR, not the source.
Test titles and the Linear URL fixture are untouched: the rule exempts them as
functional identifiers.

* fix(ok): unblock CI — drop the stale bun.lock and refit the project skill body

Two gates were red on this branch and green on main.

cap-verdict-staleness could not evaluate: public/open-knowledge/bun.lock is
absent on main (dropped in the Bun->pnpm migration) but still carried here, and
the gate refuses to compare a lockfile present on only one side.

The project skill's core body is capped at 20,000 bytes — the size an agent
loads on every activation. main sits at 19,994; this branch's rewrite (skills
as real folders, ingest as a procedure) pushed it to 20,280. Trimmed back to
20,000 by tightening wording in the passages the branch expanded. Every rule
survives; only phrasing is shorter.

* chore(ok): raise the combined-chunks budget to 3.7 MB

CI measured 3.65 MB against the 3.6 limit after two catch-up merges of main
(post-stable v0.41.3 and v0.41.4) stacked on this branch's skills work.

The main app bundle is untouched at 377 kB against its 485 kB limit, so this is
lazy-chunk and concurrent-main weight, not eager weight. Reason appended to the
budget history in package.json, as every prior bump has been.

* test(ok): fix two DOM test failures blocking test:dom

SkillEditorActions now renders a Tooltip, so its DOM test needs a
TooltipProvider wrapper.

FileSidebar.menu-action never reset the module-scoped skills-section
visible cache between tests, so the test that hides the Skills section
leaked into the View-menu test and its tree-derived gates read false.
The test passed in isolation and failed in the suite. Same reset the
sibling FileSidebar suite already does.

* test(ok): reset clears the stored skills-section preference, not just the snapshot

__resetSkillsSectionVisibleCacheForTests dropped the in-memory snapshot
only, so the next read reloaded whatever an earlier test had written to
localStorage and the preference still leaked across test files in a
worker. Local runs never reproduced it because localStorage is absent in
the environment there, so the reload always returned the default.

* test(ok): give the FileTree lazy-showall mock a setSkillsSidebar

Opening a row from the Files tree now pins the surface to Files, so
navigateWithPulse calls setSkillsSidebar. This suite's DocumentContext
mock omitted it, and the resulting throw surfaced as an unhandled error
that failed test:dom even with every test green.

* fix(ok): repair two merge-integration test failures

editor-tabs.test.ts kept main's use of projectSkillFilePath, which this
branch replaced with the skill-entry helpers; ported the assertion to
skillEntryFileLiveDocName with the same intent.

The agent-presence structural scan infers agent-write handlers from
applyPatchToFm call sites. Duplicating a skill patches the frontmatter of
a file already copied on disk: no CRDT write, no agent identity, so no
presence to broadcast. Marked that site presence-exempt and taught
discovery to honor the marker. Discovery only — removing a real handler's
setPresence still fails the shape assertions.

* test(ok): realign main's sidebar e2e with this branch's Files surface

Two specs still encode the sidebar shape from before this branch dropped
the Files project-name row (#2618): sidebar-search-pill looked for the
project actions under that row, and navigation-history addressed the
Skills surface by a button role plus test ids the rebuilt skills tree no
longer emits.

navigation-history also gained two history entries this branch
introduces: switching to Skills clears the hash, and opening a project
skill from the tree opens its SKILL.md as a content doc at its real path.
Both directions of the traversal now account for them.

* style(ok): biome formatting for the navigation-history selector

* test(ok): clear the remaining CI-only failures

Two tests were named *.dom.test.tsx but never mount React, which trips
the Tier-3 filename contract. Both need a DOM though (shadow roots, a
window global), so they move to a .dom.test.ts suffix and the DOM tier's
glob widens to cover it — same jsdom project, no React.

skill-bundle-inbound-backlink polled the link index with pollUntil's 5s
default inside a 20s test budget; a loaded runner needs more, so it now
uses 15s.

navigation-history clicked the Skills tree's Project group
unconditionally. The group's expanded state is persisted, so on a fresh
CI profile the click collapsed it and hid the skill; it now expands only
when the row isn't already showing.

* fix(ok-skills): a newly created skill is admitted without waiting on the watcher

Admitting an in-place skill dir as content is driven by raw file-watcher
events. Creating the first skill under a host dir that did not exist
before therefore left the skill unadmitted until some unrelated
ignore-file change rebuilt the filter: its SKILL.md was never parsed, so
its links and backlinks never appeared.

The create path now refreshes the allow-list itself when the file it just
wrote is still excluded, so it no longer depends on the watcher echoing
our own write. Ordinary edits skip the rebuild.

* Revert "fix(ok-skills): a newly created skill is admitted without waiting on the watcher"

The hypothesis it encoded (the skill dir not being admitted until a
watcher echo) did not change the CI failure, and it is a change to
admission behavior I cannot verify locally. Reverting rather than leaving
unverified product code in the branch.

* test(ok): report dead links too when the skill backlink never lands

The link graph only carries resolved edges, so an empty graph does not
say whether SKILL.md's link was parsed. A dead-link entry naming the
skill doc distinguishes a resolution failure from the link never being
extracted at all.

* fix(ok-skills): index a new skill's links without waiting on a watcher echo

A skill dir becomes admitted content off raw file-watcher events.
Creating the first skill under a host dir writes SKILL.md before that
admission lands, and nothing re-parses an already-written file, so the
skill's links and backlinks never appear until an unrelated ignore-file
change forces a re-walk. On a machine where the watcher reports the
create promptly this is invisible; where it does not, the skill stays
unlinked indefinitely.

The create path now rebuilds the filter itself. CI evidence: the skill's
SKILL.md produced neither a graph edge nor a dead link, so its body was
never parsed for links at all.

* Revert "fix(ok-skills): index a new skill's links without waiting on a watcher echo"

Rebuilding the filter on create does not make CI parse the skill's
SKILL.md either, so the hypothesis is wrong and the change only adds a
full filter rebuild plus index re-derive to every skill create. Reverting
rather than keeping unverified behavior in a load-bearing path.

* chore(ok): keep api-extension.ts within biome's file-size limit

The merge pushed the file past biome's 1 MiB default, at which point
biome stops checking it and emits a warning instead — and the lint job
runs with --error-on-warnings, so both the lint and structural gates went
red without a single lint violation. Raised the limit so the file keeps
being linted; splitting the module is the real fix.

* test(ok): probe doc content and the phantom path when the backlink is missing

Narrows the CI-only failure further: whether SKILL.md and the reference
doc are readable at all, and whether the link resolved to the phantom
top-level path instead of the bundle-relative one.

* chore: re-trigger CI (no workflow run was created for e95587045)

* fix(ok): flush a pending derived-index update when the document unloads

beforeUnloadDocument cancelled the debounced update instead of applying
it, so any document that unloaded inside the debounce window never had
its links or tags indexed — and nothing re-indexed it afterwards. A write
followed by a prompt unload simply never registered.

This is why creating a skill through the API left its SKILL.md with no
backlinks: the bundle-relative link resolver is pure name-shape and does
not need the target to exist, so an indexed SKILL.md would have produced
the edge immediately. CI showed neither a resolved edge nor a dead link,
which only happens when the update never ran at all. Machines where the
document happened to stay loaded past the debounce never saw it.

* fix(ok-skills): seed the derived views when a skill is written through the API

A skill created through /api/skill never reached the live-derived index:
that index refreshes on a debounce behind a change hook this write path
does not reliably reach, so the SKILL.md sat unindexed and its links
produced neither a backlink nor a dead link. create-page and the lint
path already seed the index synchronously from the bytes they wrote; the
skill handler now does the same, and the debounced pass re-applies the
same bytes.

* fix(ok-skills): converting locations no longer loses half the ledger

The placements ledger is one JSON file and each record is read-all, edit,
write-all with an await in the middle. Converting a skill's locations
fired one request per location at once, so they all read the same
starting file and overwrote each other: the folders converted fine
(separate paths), but only the last writer's record survived, and every
clobbered location then disagreed with disk and reported itself as
'changed outside'.

Ledger mutations now serialize per file, matching what the sibling
installed-skills marker already does (the global skill-state store uses a
real file lock because the CLI and desktop both write it; the ledger is
only written through the one server).

Two follow-ons: converting a location whose disk form was already right
skipped the record write too, so a drifted record could never be
reconciled from the UI — it now records either way, which repairs a stale
entry. And convert runs sequentially through the shared action, holding
the same in-flight name as install, so the skill's surfaces show it
working instead of sitting idle while folders are rewritten.

* fix(ok): drop the try/finally the React Compiler cannot lower

convertLocations wrapped its loop in a try/finally with no catch, which
the React Compiler rejects outright — it failed the app build, not just
lint. The convert call reports failures in its result rather than
throwing, so a single exit point clears the in-flight flag.

* fix(ok-skills): the source folder is never 'changed outside', and a convert closes the menu

Three things the install menu got wrong while converting.

The skill's own folder is not a placement — it is what the others project
from — but the drift check only exempted links that RESOLVE to it, not
the folder itself. A record can outlive the source moving onto that path,
and reading it as drift flagged the source as 'changed outside' on a
project nobody had touched. The skill-wide convert loop already skipped
the canonical dir for this reason.

The menu now closes when a convert starts and cannot be reopened until it
finishes: its rows come from a disk scan that refetches mid-run, so
leaving it open showed form and drift marks for a half-converted state,
flipping and then settling. Controls are disabled and the transient marks
suppressed for the run as a second line of defence, since the sidebar
surface stays mounted.

writeSkillPlacementsStore is no longer exported — the serialized mutate
helper is now its only caller.

* style(ok): reformat after un-exporting writeSkillPlacementsStore

* fix(ok-skills): MCP install matches the app's location model

The tool's `mode` applied skill-wide: any install call carrying it
converted every recorded location and persisted a preference that
outranked the derived default from then on. The app has no skill-wide
mode — a new location takes the form the skill already uses, and changing
an existing one is an explicit per-location convert — so an agent could
silently change what the app did next, with no surface showing it.

`mode` now scopes to the locations the call names, and `convert` is its
own verb that changes form without touching membership. Nothing writes a
skill-wide preference any more; the reader stays so ledgers written
before this keep behaving as their owners set them.

Also: the read tool called `mode` a stored preference, and the bundled
skill docs taught the skill-wide spelling. Both now describe the real
model.

Extracted the per-key async serializer both JSON stores had copies of —
the marker had one, and the ledger fix added a second.

* refactor(ok-skills): remove duplication the audit surfaced

Two of these were live bugs, not just repetition:

The 'uninstalled — back to a draft' toast fired on the reachable path of
unchecking the last location, but the draft state was retired — a skill
with no other locations still lives and loads at its source folder. And
one endpoint had two independently written clients that had drifted into
reporting different fields of the same error body; the hook now goes
through the one wrapper.

Dedup: the drift chip existed as four byte-identical copies across two
files; ProjectSkillPropertiesPanel differed from SkillPropertiesPanel
only by hardcoding its scope; the restore and revert handlers carried
identical error-code tables; three DELETE handlers rebuilt the same
identity-from-query-string block; the .md/.mdx strip regex was written
five times and the custom-placement root four; and every MCP tool
repeated the same three-line server-context guard.

Also removed the dead uninstall client path (no caller since the
'Uninstall everywhere' item went away) and stopped SkillRefResolution
re-declaring the scope tuple the canonical schema exists to own.

The bundled project skill sits within its 20,000-byte export budget
again — it was 104 over, which is what the mirror-runnability gate was
failing on.

* refactor(ok): share the write-side fetch shape in skills-api

Adds sendJson, the POST/PUT/DELETE counterpart to the existing getJson,
and routes the wrappers whose bodies were exactly the shared shape
through it. It deliberately does not emit skills-changed or apply
per-endpoint defaults — one endpoint intentionally does not emit, and
hiding that in the helper would make the exception invisible.

The remaining wrappers each post-process their payload differently and
are left alone rather than forced through one signature.

* refactor(ok-skills): name the one rule that still treats .ok/skills specially

Three handlers carried byte-identical spellings of 'is this placement
root inside .ok, and is it the one carve-out'. One predicate now, with
the reason written down: the store is retired, but a project skill still
living there stays placeable because in-place wins the name, while the
global store has not been migrated and still wins names, so a placement
there could hijack a skill.

Behavior unchanged — this is the surviving special case made findable
rather than resolved.

* feat(ok): section actions sit inline with the folder they act on

The create/view actions were in the window chrome, detached from what
they act on. They move onto an overarching project row — folder icon,
project name, chevron, actions inline on the right — and the header keeps
only back/forward, which are global. Skills gets the same row, with New
skill on it instead of the file actions.

This restores the shape main already had (this branch had dropped the
project row), so main's e2e assertion for the toolbar's placement goes
back to what it was.

The toolbar button was local to FileSidebar; it is now shared so both
sections present the same control.

Two fixes riding along:

.ok/skills is an ordinary placement root at both scopes now. The
project-only carve-out existed because the global store could hijack a
name, but the store is retired and refusing the path only stranded
projects that still keep skills there — which is what produced
'Placement path must be a project-relative directory outside .ok/' on an
install that had otherwise succeeded.

And switching a skill between Project and Global no longer flashes the
not-installed pill. The list is served from cache, so it reads ready
while still missing an entry whose identity just changed; a missing entry
now means unknown rather than not-installed. This pill only renders for
an open skill, and an open skill exists at minimum at its own source
folder, so that state is no longer reachable at all.

* docs(ok): give the bundled project skill real headroom under its export budget

It sat 3 bytes under the 20,000-byte limit, so any merge from main broke
the mirror gate. Rather than shaving characters again, the two sections
whose depth already lives in references/ were compressed to the rule plus
the pointer — which is what the file says it is for. Every MUST, pointer,
and command is retained; 793 bytes of headroom now.

* fix(ok): drop the Skills section row

The Files/Skills switch already names that surface, so a row repeating
the label earned nothing, and Skills has no folder for actions to belong
to the way the project row does for Files. The header tests move with it:
the chrome now holds only the traffic-light reserve and back/forward.

* fix(ok-skills): a /<skill-name> link opens that skill

Skill bodies cross-reference each other the way a slash command names a
skill — 'consumer skills (/graphics, /motion-video) load this'. Read as a
path that points at a root-level doc which does not exist, so following
one offered to CREATE a page instead of opening the skill.

Resolved at the shared href-to-target chokepoint, beside the existing
bundle-ref overlay, so the click, the dead-link styling, and the link
index all agree. Scoped tightly: it only applies from inside a skill, a
single segment only (/a/b stays a path), and it resolves against the
source skill's own root, so it finds siblings wherever that bundle
lives.

* fix(ok-skills): a new location follows the skill's existing form

The default keyed off the COUNT of existing locations — symlink while a
skill lived in exactly one place, copy once it lived in two — so the form
a location got depended on how many installs happened to precede it.
Installing into three agents produced a symlink and then two copies, and
the divergence badges appeared and vanished as the expected form flipped
underneath them.

It now reads the form the existing locations actually use: symlink when
there are none to follow, and copies only for a skill whose locations are
already copies, so a new one matches its siblings instead of silently
mixing forms.

Also removes the New skill button from the sidebar chrome.

* fix(ok-skills): address the PR review findings

Website-skill fetches followed redirects with the origin gate only ever
seeing the FIRST url, so an https origin could bounce to http, to another
host, or to a link-local address and the check would never see it. Each
hop is now re-validated as https and same-origin, bounded at five.

The fork rename patched SKILL.md with a multiline ^name: regex, which
matches the first such line anywhere — a body line reading 'name: …' got
rewritten instead of the frontmatter. It goes through the same
detectFmRegion + applyPatchToFm pair the duplicate handler uses.

Five raw node:fs calls in the fork-resolution branches bypassed the
traced wrappers, against the STOP rule; they are traced now, which left
the raw fs imports unused.

The placement comment still described .ok/skills as refused at global
scope, which stopped being true when it became an ordinary root.

Also regenerates the lingui catalogs — the drift is what the lint job
was failing on, not a lint violation.

* fix(ok-skills): a location no longer flashes a form tag it never earned

Installing a second location briefly showed COPY on it before settling to
symlink. Two causes, both about reading state mid-write.

The form tag was not gated on the in-flight flag while the drift mark
beside it was — so the two disagreed about when the row was safe to read.
Both are gated now, on the host rows and the custom-root rows.

And the flag itself ended too early: it cleared when the request
returned, but the write triggers a watcher refetch, so the menu spent the
window between those two moments rendering pre-write state as if it were
settled. A write now counts as done when the list reflects it, not when
its request returns.

* fix(ok-skills): a half-completed fork rename is no longer reported as success

The folder move happens before the frontmatter patch, so a failed patch
left the skill renamed on disk while its SKILL.md still declared the old
name — and the response was a 200 with no warnings, which reads as a
clean rename. It now returns a warning naming the mismatch and what to
fix, under a registered skill-fork-name-unpatched code.

* fix(ok-skills): audit findings — security, silent failures, and naming

The git-transport allowlist existed as two byte-identical copies kept in
step by a comment. One edit to either side reopens the ext:: / fd:: hole
on the other, so core now owns the list and the server consumes it; the
server's BLOCKED patterns stay additive on top.

MCP import projected into editor host dirs on every call while its own
text said projection was the separate install verb — the server projects
unless told not to and the tool never said not to. It now writes the hub
copy only unless asked, and surfaces the candidate names a multi-skill
source returns instead of telling the agent to pick from a list it never
shows. install's description also still claimed mode persists skill-wide,
contradicting its own schema, and never mentioned convert.

Rename and cross-scope move hardcoded 'symlink', so either one silently
converted an imported skill's copies back to symlinks and reintroduced
the dangle-on-clone the copy rule exists to prevent. All three paths now
derive the form the same way.

Placing, unplacing, and make-source swallowed failures while the same
controls on a preview reported them, and a folder unlink waited on the
cross-client signal the rest of the surface bypasses.

Naming: ImportSkillDialog held no dialog and ImportSkillModal did, so the
names swap and the odd 'Modal' word goes; SkillsHub was a pass-through
around SkillsBasePage; a fourth copy of the DELETE identity block now
uses the shared helper.

* feat(ok-skills): MCP import requires where it goes

Import acquired a skill and left placement to a second call, which meant
the common flow was two calls and the in-between state — acquired,
placed nowhere — was representable at all. `add` is now required and
uses the same location vocabulary `install` does, so one call fetches,
records provenance, and places, while `install` keeps what only it can
do: remove, convert, and move the source.

Placement runs through the install endpoint rather than a second copy of
the location math, and a placement failure says so instead of reporting a
clean import — the skill exists at its source folder either way.

The verbs stay separate deliberately: import is acquisition (network,
provenance, name-conflict rename), install is placement. Merging them
would put network access behind an argument on a verb that otherwise
only moves local folders, and the app keeps the same two functions.

* docs(ok-skills): retire the last Draft references and clean the authoring guide

Draft was removed as a state but survived in a dozen comments, tool
descriptions, and schema docs — including move's user-facing text telling
agents a cross-level move 'lands as a Draft', and the uninstall route's
own comment. Each now says what actually happens: the skill keeps loading
from its own folder.

The authoring guide's install stage had grown into one paragraph mixing
three questions; it now answers them separately — which form a location
takes, what each form means, and lifecycle. Stage 1 no longer says
install fans an import out, since import places it directly.

* fix(ok-skills): the three open surface inconsistencies

A bulk add/remove coded three different failures as name-conflict: a root
that is not placeable at all, a hand-edited copy refused on remove, and a
real name collision. The single-shot verbs already answer those with 400,
409, and 409, so an agent using the bulk form could not tell them apart —
and only ever saw a 200. They now carry place-path-invalid and
place-fork-refused, and the MCP install tool forwards the codes instead
of dropping them, since a bulk call succeeds on the locations it can do
and the codes are the only record of which ones it could not.

The legacy store-backed branch ignored an explicit mode and used the
import-origin default, so the same call was answered two ways depending
on which store the skill happened to live in.

And the skills read tool handed back real filesystem paths with no
indication they are informational, in a system whose own guidance says
skills are addressed by name and never by path.

* fix(ok): tidy sidebar chrome alignment and project row styling (#3022)

Right-align the sidebar header's action cluster with everything stacked
below it. The header was px-3 while the pill row is px-2, so the
back/forward arrows sat 4px right of the search button directly beneath
them. The header now uses pl-3 pr-2: the left gutter still matches
EditorHeader's zone inset, and the right edge joins the search button and
the Pierre tree rows on a single 8px column.

Add a --color-tree-muted theme token as the one definition of the file
tree's muted foreground. Pierre declares --trees-fg-muted on :host inside
its shadow root, so sidebar chrome rendered outside the tree cannot read
it. createFileTreeStyle now feeds the token into the tree and out-of-tree
rows use the generated text-tree-muted utility, so the two cannot drift.

Restyle the project row: the disclosure chevron leads, the folder icon is
gone, and the name takes the muted treatment. Bump the skills scope label
to 0.75rem with its own color declaration so it reads darker than the
twisty beside it.

* fix(ok-skills): address final audit findings

* fix(ok): harden skill acquisition + settle location writes before the list repaints

Security:
- crafted .zip could kill the server: fs errors thrown inside the archive
  stream 'end' listener escaped as an uncaughtException
- install { add | remove } used lexical path containment while place/unplace/
  convert used the symlink-aware resolver, so a checked-in symlink root could
  place a bundle outside the project
- import size caps ran after the whole bundle was read into memory; pre-flight
  them by stat instead
- discoverSkillDirs had no node budget, so a local source of ~ stat-walked the
  whole home dir on the thread serving the collab websocket
- add the three missing mutating skill routes to the loopback/Host gate

Behavior:
- source move, custom placement and un-placement now hold the shared in-flight
  flag, so the install menu stops painting a transient COPY badge on a source
  mid-relocation
- skills-discover reads SKILL.md only instead of every byte of every bundle

Also: split the misleading no-hosts-installed telemetry reason, guard
deriveSkillMoves against two skills claiming one name, and drop the stale
skill-wide mode-preference wording from the HTTP schema.

* fix(ok): keep imported-skill provenance across rename and scope move

An imported skill's lockfile entry stayed keyed under its old name on rename,
and stayed in the source scope's lockfile on a scope move. projectionModeFor
then looked up a key that was not there and re-projected as symlinks, so
copies silently became links. The orphaned entry was the bigger loss: the
skill stopped being reimportable at all.

Re-key on rename and transfer on scope move, refreshing localHash and the
shadow baselineRef at the destination (global drops baselineRef, it has no
shadow repo).

Auto-update no longer defaults on for remote sources. Opening an imported
skill fetched upstream and applied it with no diff and no confirmation, so a
publisher could change what a user's agents do after the fact. Local paths
keep the stay-in-sync default. The toggle now persists both choices, since
absent means the source-kind default rather than on.

Seed the derived views on the skill PUT path too. Only one of the two write
paths seeded the backlink index, which is why the bundle-backlink test failed
on CI. That test now asserts immediately instead of polling for 15s, so it
proves the seed rather than tolerating a debounce that happens to land.

* ci: let the openknowledge-skills mirror fail without stranding downstream cleanup

The four skills-mirror steps had no continue-on-error, so a failure skipped
every later step in the job, including the agents cascade's PR-resolution
cleanup. Placing them last avoided gating the release but still left that
cleanup unrun.

Each step now records its outcome and gates on the previous one succeeding,
and a final step re-raises after the downstream mirrors finish, so the
tracking-issue signal survives while cleanup still runs. Also trigger the
workflow on the skills manifest and the config generator, which both change
what this job mirrors.

* feat(ok-skills): put the input first on the skills empty state

The Skills home led with three source cards of equal weight, so the two
fastest paths (describe a skill and let an agent write it, or install one
that already exists) were no more prominent than starting from a blank file.
The input now leads, Upload and New from scratch sit beneath it as quiet text
actions, and the six most popular skills.sh skills render inline with a
Browse all link into the full directory.

Those rows reuse the Explore modal's card instead of a lookalike. The
publisher avatar, install meta, first-party mark, Added badge and the
already-imported detection moved out of ExploreSkills into
SkillDirectoryResult and useSkillDirectory, so a skill reads and behaves the
same on both surfaces and one place decides what a click does. A failed
skills.sh fetch hides the shelf rather than stranding an empty grid.

Install counts read 2.7M instead of 2733k. formatInstalls only had a
thousands branch, which stayed invisible while the Explore modal was its only
caller and became obvious once six rows landed on the home screen. It now
calls Intl.NumberFormat compact notation, which also settles a locale bug the
hand-rolled version had: the unit, its abbreviation and the decimal separator
are the active locale's rather than en-US's. Lingui ships i18n.number, but
its own typings mark that helper for removal in favor of exactly this call,
so the formatter is constructed directly. CLDR's default compact rounding is
left alone (no maximumFractionDigits) because it already gives 589K rather
than 589.4K, and the empty locale Lingui reports before catalog activation
would throw RangeError, so it falls back to the runtime locale.

* feat(ok-skills): install a plugin's other skills from one clone

The 'part of a plugin' disclosure on a skill preview now opens a picker for
the plugin's bundled siblings instead of only linking out. Whatever is
selected imports through a single server-side clone via the new
POST /api/skills/import-bulk, so ten skills from one repo no longer clone it
ten times.

Deliberately a picker, not a one-click 'install all': dozens of unreviewed
skills competing to trigger is worse than the few the user wanted.

The shared import spine is now pure (runSkillImport returns an outcome,
respondSkillImport writes the single-skill response), which is what lets the
bulk handler run it once per selected skill against one fetch and report
per-skill results. A name absent from the source or an oversized bundle
reports itself rather than failing the whole selection. Website
(.well-known) sources materialize one skill per fetch, so those get a
per-skill fetch instead of a shared clone.

* fix(ok): import surfaces its placement warnings, not just the acquire's

import runs two steps: acquire, then place through the install endpoint. It
reported only the acquire's warnings, so a bulk add where one location was
refused as a hand-edited fork or rejected as an unplaceable root came back
looking like a clean import. install was just taught to forward warningCodes;
calling it internally and dropping them defeated that.

Merge both halves' warnings, forward warningCodes with the same vocabulary,
and stop claiming 'no changes' on an already-imported skill whose placement
did in fact just run.

* fix(ok-skills): repoint the success-spine pin at respondSkillImport

Splitting the import spine into a pure runSkillImport plus a response writer
renamed the delegated-to emitter, so the envelope meta-test still pinned
finishSkillImport and reported handleSkillImport and handleSkillUpload as
missing successResponse. The spine probe also required 'async function',
which the now-sync writer would never match: an unfound spine passes the
body assertions vacuously, so the probe accepts both forms.

* fix(ok-skills): address review on the skills empty state

Browse skills.sh becomes a third quiet action beside Upload and New from
scratch. It sits outside the popular shelf on purpose: the shelf hides when
skills.sh is unreachable, and a reviewer pointed out that took the page's only
labeled path into the directory with it. Search still works during a skills.sh
outage via the GitHub fallback, so that path stays useful and should not
disappear. Browse all now renders only once the cards do, since it was the one
focusable control inside a section that unmounts on a failed fetch, which could
drop a keyboard user's focus to the body.

The composer also steps aside for an open sessions dock, matching what
EmptyEditorState already does for a docked terminal: the agent thread in it is
its own dispatch affordance, so a second one competes with it. The add actions
keep their quiet treatment in every state.

The popular list moves to a shared useQuery hook. Both surfaces were fetching
/api/skills/popular independently with hand-rolled cancellation, so opening the
Explore modal over the Skills home fired the request twice; react-query is the
established fetch primitive here (provider at main.tsx, eight modules use it).

The already-imported matcher is extracted as a pure findImportedSkill and
tested. It carried the real judgment of the extraction, was newly shared by two
surfaces, and had no coverage at all: eight cases now pin the three name shapes
plus the same-source requirement that keeps siblings of a multi-skill repo
independently importable.

Also from review: the install count's down-arrow is hidden from screen readers
and the number gets an sr-only label, so it no longer announces as "down arrow
2.7M"; the Explore label matches the home shelf's element and classes; and
three comments that had gone stale are corrected, including a JSDoc claiming
the hook injected scope and the badge reported it, when neither is true.

* feat(ok-skills): disclose a website source's other skills, stop duplicate preview tabs

A site source publishes its skills through a .well-known index and has no
plugin manifest, so a preview of one skill never mentioned the rest.
open.feishu.cn publishes 27. The preview now enumerates the index for site
sources and feeds the same picker a plugin repo gets, so the banner and the
bulk import work for both. Enumeration is site-only: a repo preview already
answered this from its own clone, and asking again would clone it twice per
open.

Duplicate preview tabs: a preview's tab identity includes its source path, and
that path moves. A plugin-cache path carries the plugin version
(.../cache/team/eng/1.2.679/skills/x), and a detected skill relocates when its
installed copy is deleted and it is re-detected where it came from. Either
minted a second tab labelled exactly like the first, and closing one left its
twin behind, which is what reads as an un-closable tab. Reuse now covers every
local-path flavor, not just built-ins.

Independent of that: a tab the user can see is now always closable. Closing an
id that is missing from the open-tab list used to return silently, leaving
whatever rendered it stuck on screen.

* chore(ok): refresh the ng-anchors catalog after the main merge

The merge brought two new test files under the audit's globs without a
regenerate, so the freshness test failed on a count only.

* fix(ok-skills): an unreadable skill no longer takes the whole bulk import down

Per-skill failure isolation only held for failures the spine RETURNS. The
pre-flight size walk stats and reads the tree, so an unreadable bundle
(EACCES/EPERM, or an ENOENT in the TOCTOU window) threw instead, and since the
response is written after the loop, one such skill discarded every result
already collected.

Three layers, each correct on its own: the size pre-flight reports an
unreadable bundle as a refusal rather than throwing, which is what its
string-or-null contract already promised its callers; directory enumeration
skips a directory it cannot read instead of failing the whole walk, so one
locked sibling no longer breaks the preview, the picker, and the import alike;
and the bulk loop catches anything left.

Test locks a bundle's mode bits and proves the sibling in the same selection
still lands. Raised in review on PR #2923.

* fix(ok-skills): bulk import from a website source, and pick the agents it installs into

Two things the first cut got wrong, both visible the moment the picker met a
real site.

A website source refuses to resolve without a skill name — there is no repo to
point at, only the index's per-skill entries — so passing none made every
site-source bulk import fail with 'Could not fetch source' before it started.
Any requested name answers the same question, so the first one is the probe;
the site branch then re-fetches with each selected name in turn. Verified
against open.feishu.cn: two of its 27 skills import in one pass.

The picker also chose skills but not destinations, leaving the server to
auto-project into whatever it detected. That is the one thing a destination
picker exists to prevent, so the dialog now carries the agent checkboxes,
seeded from the project's configured targets. Acquire runs with install:false
and each skill is projected explicitly, set-exact, the same contract the
single-skill menu uses. No agents selected is a stated outcome, not a failure:
the skills are saved, just not installed anywhere.

* fix(ok-skills): one preview, one tab id — always encode the level

The duplicate lark-doc tabs were two ids for one preview:

  skill-preview:explore/open.feishu.cn/lark-doc/open.feishu.cn/project
  skill-preview:explore/open.feishu.cn/lark-doc/open.feishu.cn

Same flavor, source, name, subtitle. The identity encoder appended the level
only when the caller passed one, so a caller that named it and a caller that
did not minted different tab ids for the same skill — two tabs with the same
label, and closing one left its twin, which is what read as an un-closable
tab. The earlier fix covered a moving source path; this is the other half and
the one this report actually hit.

The level is now always encoded, defaulted to the same value the preview
component already falls back to, so no behavior changes — there is simply one
spelling. That also drops the empty-segment padding the path encoding needed.

Test pins the two spellings to one identity, so a future optional coordinate
cannot reintroduce the split.

The SkillPreviewTab dom mocks also had to grow the exports the bundle picker
pulls through the banner.

* perf(ok-skills): stop serializing website skill downloads

Installing two skills from open.feishu.cn took 39 seconds, which reads as a
hang. Almost all of it was round trips taken one at a time: a bundle is
routinely 20+ small reference files fetched sequentially, and every skill in a
selection re-read the origin's index (probing both candidate paths) before
fetching anything.

Files in a bundle now download concurrently, bounded so a large bundle cannot
open a connection per file against one host. A caller fetching several skills
from one origin can read the index once and pass it back through the fetch
options; the bulk import does. Same two skills: 9 seconds.

The per-file cap still bounds what any single response contributes, which is
what keeps the total-bytes check meaningful once downloads overlap.

* ci: restore the open-knowledge-skills mirror failure isolation

Adopting main's mirror scaffold reverted this hardening, and the merge also
moved the four skills steps ahead of the agents cascade. With no
continue-on-error, a skills failure skipped every later step, so one bad
optional sync would strand npm publish, the release handshake and the Vercel
deploy behind it.

Each step records its outcome and gates on the previous one, and a gate after
the last mirror re-raises so the tracking issue still fires. Also restore the
manifest and config-generator path triggers: for this target the manifest is
the projection, so a manifest-only change has to sync.

* fix(ok): cap SKILL.md itself, and keep the import name-probe metadata-only

The bundle size pre-flight seeded its running total from SKILL.md but only
checked the total inside the walk, which skips SKILL.md — so a bundle whose
only file was one enormous SKILL.md reached no check and read as fitting. That
is the cheapest version of the heap exhaustion the pre-flight exists to stop.

The single-import name probe also still used parseSkillDir, reading and
hashing every byte of every bundle in the clone before the pre-flight could
refuse any of it. Two of the three probe sites already used readSkillDirMeta.

* style(ok): apply biome formatting to the install root-guard condition

* fix(ok-skills): placing a skill where it already lives is success, not an invalid path

Installing agent-browser from skills.sh raised 'Placement path must be a
project-relative directory outside .ok/.' on an install that had in fact
worked. Imports land IN-PLACE at the .agents/skills hub, so placing a freshly
imported skill into that hub names its own canonical dir — and the placement
guard lumped that in with escapes and .ok/ internals as a bad path.

Being already at the requested location is a satisfied request. It now returns
the location and changes nothing; a copy or symlink there would be
self-referential. Fixed in the guard rather than in the hub toggle that
happened to hit it, so install --place and the MCP verb stop erroring too.

The genuine refusals are untouched: empty dir, a path escaping the base, and
.ok/ internals.

* fix(ok): a bare install no longer relocates the source or strips locations

install({ name }) with no targets sent nothing, so the server resolved the
DETECTED editors and treated that as a set-exact list. The detected list can
never contain the .agents hub, so a hub source read as unchecked: the real
folder was moved into an editor dir and every location the detected list did
not name was deleted. Four other tools' success messages said 'Run install'
after any skill change, pointing straight at that call shape.

Relocation and set-exact removal now require an explicit targets list, the
same reasoning the adjacent carve-outs already use for  and a
custom-root source. The nudges are gone where copies auto-refresh or the verb
already re-projects, and name  where a location genuinely has to be added.

Serialize and atomically write .ok/skills-lock.json. Nine write sites did
read-then-write with a network clone in the middle, so a concurrent import lost
its provenance entry and silently stopped being reimportable, while the
gitignored ledger beside it had both protections. New skills-lock-store.ts
mirrors skill-placements-store.ts.

Restore validated against the retired .ok/skills path, so every restore of a
normal skill returned a false 'no longer validates' warning with an absolute
path in it.

* fix(ok): scope install's mode to the locations it adds, drop the dead skill-targets store

The endpoint's mode applies to the whole resulting location set, so forwarding
it alongside add converted siblings the caller never named: add ['cursor'] with
mode copy also turned the existing claude and codex symlinks into copies. The
tool's mode is documented as scoped to add, so it now applies as a per-location
convert on exactly those locations.

ok skills import told users the skill went to .ok/skills and to install it from
Settings > Skills. Both are wrong since the store was retired, and that pane
can no longer install to editors, so the instruction was a dead end.

Delete the skill-targets file-store half: nothing has read or written
.ok/skill-targets.json since its store module was removed, and the header's
claim that the shape is kept so an existing file still parses was false.
SkillTargetEditorSchema and isSkillInstallTarget stay - both are live.

* fix(ok): make the published skills repo findable and correct the skills docs

The skills repo README said the catalog was intentionally empty while 18
skills shipped beside it, and documented no install path. template/SKILL.md
declared name: my-skill-name against directory template, so every crawler that
indexes SKILL.md frontmatter would have picked up a junk entry from the repo
whose whole purpose is feeding those catalogs; it is now SKILL.md.example.

Nothing in docs/ referenced the repo, the plugin marketplace, or the install
command, so a reader could not find what we publish. skills.mdx now names it,
and documents the auto-update gate that was invisible: a remote skill's
instructions never change without an explicit per-skill opt-in.

Corrected doc claims that described removed behavior: the Detected/Adopt flow
(neither the tab nor the action exists), the authored-vs-imported symlink rule,
New asking for a scope, re-installing after a scope move, .ok/skill-targets.json
as a committed file teammates inherit, and ~/.ok/skills as an authored store.
The release notes described a four-tab import modal and an Adopt-all toggle that
were never built, and a changeset claimed the tool surface dropped to 18 when it
is 21 both before and after.

Wire the two omitted install-menu props so the fork-resolve chip and the
mid-run popover close work in the sidebar as they do in the toolbar, label the
folder-unfollow button (an unlabeled destructive control), and give the
injected install cluster role=img so its aria-label is actually announced.

* fix(ok): one operation per install call, and surface the server's remedy

The install handler is a first-match-wins chain of early returns, so a request
naming two operations performed one and dropped the other with a 200 and an
unchanged-looking hosts array: { add, convert } silently discarded the add. The
schema now makes the combination unrepresentable instead of relying on callers
to know the precedence order.

install printed only the RFC 9457 title, while the server puts the actionable
sentence in detail — 'Skill not found.' instead of 'Skill "x" not found in
project scope — create it with write({ skill }) first.' The agent got the
diagnosis without the remedy. errorTextWithDetail now carries both, and import
uses the same helper.

Add install.test.ts: the most destructive tool on the skills surface had no
test file at all, only a registry entry asserting its name. It pins the request
bodies, which is where both historic defects lived.

* chore(ok): regenerate Lingui catalogs for the folder-unfollow aria-label

* fix(ci): restore the two mirror include entries the merge dropped

The public-open-knowledge manifest lost coverage for
scripts/check-husky-prepare-guard.sh and scripts/husky-prepare.sh, both still
referenced by the mirrored package.json's prepare and check:drift:guards
scripts. The public clone would have shipped a package.json whose prepare step
points at a file that was never exported - the PR #599 bug class the
check:mirror-includes guard exists to catch, and the guard did catch it.

* fix(ok): close the skills/move MCP contract gaps

move's outputSchema omitted skippedBinaryFiles, which moveSkillCrossScope
returns. Output schemas are strict, so a client discards the WHOLE result: the
cross-level move already deleted the source, and the agent lost both the
success confirmation and the only notice that a binary bundle file did not
travel.

skills accepted scope and ignored it in LIST mode, so an agent asking for its
global skills got every project skill too. It now filters.

mode was omitted unless the server reported linkMode, making 'this skill uses
copies' indistinguishable from 'unknown' — the natural repair is to guess, and
guessing wrong reshapes locations. It is now always answered.

A query shorter than 2 chars returned an empty result set labelled
backend: skills.sh, claiming the marketplace had answered when no request was
made; it is now refused. The bundle-file path validator's normalized output was
computed and thrown away, leaving the raw string to go on the wire.

installed is hardcoded true for every managed row — its describe now says so
instead of promising a per-location answer, and committed's describe covers the
skill paths that return it.

* fix(ok): STOP-rule fs wrappers, positive git-transport gate, upload identity

content/skills-write.ts wrote with raw node:fs at 13 sites, so skill-bundle
writes emitted no fs.* span while every sibling skills module was traced. The
branch added three of those; converted the file wholesale so the next writer
inherits the traced set.

fetchSource's defense-in-depth gate blocked only ext::/fd:: while its own
comment claimed it enforced the transport allowlist. A hand-built
{ kind: 'git', url: 'file:///…' } reached git clone even though parseSource
routes file:// to kind: 'local' and the server's isAllowedGitUrl rejects it.
It now enforces the positive allowlist for anything carrying a scheme, leaving
bare filesystem paths cloneable.

/api/skill-upload had no method guard and lifted only agentId from the query,
dropping agentName, colorSeed, clientName and summary that every sibling
mutating route threads — so an uploaded skill's version had an anonymous author
and no summary while the identical import spine recorded both.

skill-file-delete recorded a shadow-commit title under .ok/skills/<name>/,
a path that no longer exists, and that title shows in /api/history.

harnessSkillDir rendered .copilot/skills and .pi/agent/skills at project scope
where the scanner actually looked in .github/skills and .pi/skills; deleted in
favour of skillHostRootDir, which is scope-aware. Also drop the inert skills:hub
tab-id cluster (isValidTabId rejects the prefix, so it could never open), a
pure type alias, and a duplicated ignore-set.

* fix(ok): one install-state derivation, and no dead controls on the preview

The sidebar row rebuilt the install-icon cluster inline and omitted
customPlacements, so a skill placed only at a custom root read 'Install' in the
sidebar and 'Installed' in the toolbar at the same moment. Its comment claimed
the two derivations matched. skillClusterHosts is now the single one, with the
host list overridable so the toolbar can pass its optimistic overlay.

The preview stubbed the four location verbs to no-ops while the menu rendered
their controls, so convert-all offered a confirm dialog naming real paths and
then did nothing. The verbs are optional on the interface and the preview omits
them, so a surface that cannot perform an action cannot render its control —
TypeScript found all five call sites rather than review having to.

* fix(ok): drop the now-internal alias-viewer export and the dead hub tab id

skillClusterHosts absorbed skillAliasViewers' only external caller, so knip
flagged the export - dropped, and the sidebar comment that pointed at it now
names the shared derivation instead.

The editor-tabs tests built a Skills hub id through a production helper that
only existed for them. The prune path that keeps a pre-retirement persisted id
from restoring IS live, so they construct the legacy id literally and the
helper is gone.

* style(ok): apply formatting after the main merge

* fix(ok): stop deriving skill doc names from the retired store layout

Three agent- and user-facing paths built a doc name as .ok/skills/<name>/...
instead of the skill's real bundle dir, so each addressed something that does
not exist:

- A skill cross-reference resolved through resolveSkillRef navigated a project
  skill to a phantom empty tab. The sibling branch 8 lines up already used the
  entry's real path; resolve-ref now returns  so this one can too.
- history({ skill }) queried the store path and /api/history takes docName
  literally, so an in-place skill's timeline came back empty and its preview
  pointed at a missing doc. It resolves the real dir first.
- okArtifactKey('skill') keyed contributor attribution to the store on nine
  write paths, so edit / move / duplicate / bundle-file commits failed the
  OkActor match and never appeared in the skill's own history. The create path
  was already fixed and its comment states the rule the rest violated.

Also type the MCP warningCodes as the shared enum rather than bare strings,
and document all seven codes — no-targets, the most likely outcome of the most
likely call, was undocumented.

* fix(ok): a freshly created skill's links reach the derived index again

The main merge routed skill-write seeding through
derivedDocumentIndex.recordDirectDocument, which admits a doc via
contentFilter.isExcluded. A skill dir only enters that allow-list on
rebuildIgnorePatterns(), and an API write never triggers one — so a
just-created skill was judged excluded and the seed DELETED it from the index
rather than indexing it. Its links were never extracted, and nothing pointed at
its references until an unrelated rescan.

The pre-merge code called backlinkIndex.updateDocumentFromMarkdown directly,
with no admission gate, which is why this did not show up until now. Refresh
the allow-list before seeding, fail-soft like the watcher's own rebuild.

Caught by skill-bundle-inbound-backlink, which asserts immediately instead of
polling for 15s — the poll would have let an unrelated debounce mask it.

* fix(ok-skills): keep the derived-index seed best-effort, and say when the lockfile is corrupt

Porting seedSkillDerivedViews onto main's derived-index port during the merge
turned a best-effort refresh into a hard await, so a transient index error
would 500 a skill-put whose bytes were already on disk. It goes through
recordDerivedDocumentBestEffort now, like every other durable-write projection;
the skill-file-rename projection gets the same treatment for the same reason:
the rename already happened, and losing the index update is not worth failing
the request over.

A corrupt skills-lock.json still fails soft to an empty lock, which is
deliberate, but it did so in complete silence — the operator had no signal that
origin, Modified, and Revert had just been dropped for every imported skill in
the project. It logs now, and only when the file is present and unparseable, so
a first-run absence stays quiet.

Both raised in review on PR #2923.

* fix(ok): treat .ok/skills as an ordinary custom root

Placing a skill at .ok/skills and placing one at .team/skills promised the same
thing and did not deliver it: the boot drain relocated the .ok/skills one on
next launch, because it recognised a deliberate placement only when the same
skill also lived somewhere in-place. No other custom root has its contents moved
out from under the user. The drain now reads the placements ledger the placement
verb already writes, so a recorded placement is skipped and only unrecorded
residue drains.

Drop the brand mark: .ok/skills rendered OK's own blob in the locations menu
while every other custom root got a folder, which read as OK's official home for
skills - the standing the retired store no longer has.

globalSkillGraphRoots hand-prepended .ok/skills as the one non-standard root
wired into the global watcher and graph. It now builds from knownSkillRootsFor,
so a global skill at ANY custom root is covered, with the store appended only
because residue that collides at its migration target never drains.

The content-filter carve-out stays and is now documented as deliberate rather
than stale: .ok/skills is the one skill root inside .ok/, which OK hides from
git wholesale in local-only mode, and git cannot re-include a path whose parent
is excluded. Removing it would make skills there invisible while still on disk.
That is also why this root can never be QUITE as ordinary as .tim/skills.

* fix(ok): make skill seeding best-effort and the size pre-flight total

seedSkillDerivedViews called recordDirectDocument bare while every other
derived-index mutation goes through recordDerivedDocumentBestEffort. Both call
sites sit inside handleSkillPut's outer try/catch, so an index error during
shutdown turned an already-committed skill write into a 500 the caller would
retry. It now uses the same helper, keeping the allow-list refresh that gets a
new skill's links indexed at all.

acquiredBundleTooLarge promises callers 'the reason to refuse, or null', but
only its fs calls were guarded — a pathologically deep tree could still throw
out of the walk and, in a bulk import, take the whole selection down. The walk
is now wrapped, so the contract holds.

* fix(ci): a public skills PR can no longer break the mirror export

skills/ in the public repo is a PROJECTION of the assets tree, not a copy, but
the bridge had no path rewrites — so a contributor PR editing skills/x/SKILL.md
bridged to public/open-knowledge-skills/skills/x/SKILL.md, which the manifest
included and which maps to the same output path as the projection. Copybara
refuses to overwrite, so one accepted OSS contribution would kill the export,
and CONTRIBUTING.md actively invited that PR.

A static rewrite map cannot express the projection (a pack's source dir does not
match its frontmatter name), so exclude the bridged path from the manifest and
say plainly in CONTRIBUTING that skills/ is generated. The projection itself is
unaffected: the export still ships 44 files with all 18 skills.

* refactor(ok): rename SKILL_CONTENT_ROOT to LEGACY_SKILL_STORE_ROOT

The name read as 'the content root for skills' and its docstring claimed the
path held 'every skill's source dir'. It is the retired store. That framing is
the root cause behind most of the remaining .ok/skills special-casing: it is
what the unconditional content-filter carve-out, the store-shaped doc-name
helpers, and the restore default were all built on, and each of them looked
reasonable at the call site because of the name.

The docstring now says what is true — skills live in place; this survives for
undrained residents, pre-migration version restores, and the git carve-out —
and names the three legitimate uses so a fourth stands out.

PRECEDENTS.md's skills worked example gets a corrigendum rather than a rewrite,
per the house rule on shipped prose: the two-axis storage-locality ruling
stands, its .ok/skills storage detail and 'install = symlink, not copy' do not.

Also drop the test assertion pinning the legacy store as the FIRST global skill
root — that primacy is exactly what an ordinary root does not get.

* fix(ok): clear stale site siblings, and log a corrupt skills-lock

Switching between two site-typed previews kept the previous source's sibling
list until the new discover resolved, so the bundle disclosure briefly offered
to import skills belonging to a different publisher. Clear before fetching.

A corrupt skills-lock.json fails soft to empty, which is right - it must not
break an import - but silently degrades every imported skill in the project to
'never imported' at once. Logged, an operator can restore it from git before
the next write bakes the empty state in.

* refactor(ok): one wrapper for GET /api/skills

use-open-skill and skill-name-set fetched the list directly and cast the body
with `as { skills?: SkillsListEntry[] }`, which silently accepts a response the
.strict() schema would reject — so server field drift was loud in use-skills and
invisible in the other two. listSkills validates once, and its error surfaces
the server's message instead of a bare HTTP status.

* chore(ok): regenerate Lingui catalogs after the open-skill error string change

* fix(ok-skills): preview tab leaves preview mode after a bundle install

A bulk install runs entirely inside the plugin banner, so the hosting
preview tab never learned the skill had landed. It kept reading "this is a
preview of <name>", its slash-links stayed dead (preview mode disables
them), and its INSTALL menu stayed armed one click from re-importing as
<name>-imported. The banner now reports what landed and the tab redirects
to the real skill.

* fix(ok-skills): bundle-shaped doc no longer empties the tab strip

isSkillFocusedTarget (which picks the sidebar) counted any **/skills/<x>/SKILL
path as a skill; isSkillTabId (which filters the tab strip) only counted
dot-prefixed roots. Opening a doc the two disagreed on set skillFocused=true
while the doc's own tab was not a skill surface, so the mode filter matched
nothing: empty tab strip, dead Ctrl+Tab and Cmd+1..9, and a Files toggle that
could not re-arm. This repo's own packages/server/assets/skills/project/SKILL
hits it. The predicate now lives in editor-tabs so both sides read one copy.

* fix(ok-skills): stop creating vendor skill dirs in $HOME on every boot

The global-root widening handed all nine known roots to the watcher, which
mkdir -p's whatever it is given. A plain markdown user grew ~/.gemini,
~/.pi, ~/.opencode and friends just by starting OK. Watch only roots whose
parent already exists, so an installed tool still gets its skills/ leaf and
an absent one is left alone.

The same widening also silently repointed the skill-state watcher: it derived
the path as skillsRoots[0]/.. , which meant ~/.ok/skill-state.yml only while
.ok/skills was the sole global root and now lands on an editor dir. Address
it directly.

* fix(ok-skills): listSkills rejected every well-formed response

getJson merges its own ok:true into the body, and SkillsListSuccessSchema is
.strict(), so safeParse never succeeded: the wrapper reported a schema failure
on good data. Both consumers went quiet with it - opening a project skill by
name toasted an error, and the slash-ref name set stayed empty. Validate the
server's fields, not the envelope. Also reattaches the fetchSkillDetail
docstring the wrapper was inserted in front of.

* fix(ok-skills): a skill rename now carries its inbound /name refs

A document rename rewrites inbound links; a skill rename did none of that,
and the gap was invisible rather than merely inconsistent. Skill refs resolve
by NAME at read time, so renaming a skill silently dropped every inbound edge:
the referencing body still said /oldname, nothing answered to it, and dead-link
detection never saw it (computed edges never enter the backward map that
getDeadLinks walks). A later skill claiming the freed name inherited the stale
refs.

rewriteSkillRefs sits next to extractSkillRefs and is deliberately symmetric
with it: exactly the occurrences that draw an edge are the ones a rename
carries, so path continuations and stop-listed roots stay put. The move handler
sweeps same-scope bundles and re-indexes the bodies it changed.

* fix(copybara): derived skill moves tolerate a backlog older than the skill

Derived moves are computed from current disk, but ITERATIVE replays them
against every un-imported origin revision - including ones predating the skill
they name, where the move source does not exist. An unguarded no-op move aborts
the migration, so the first sync after merge could wedge on a backlog that
already contains five such revisions. Wrap only the derived moves in
ignore_noop; hand-written moves stay strict so a typo in one still fails loudly.

Export is unchanged: 44 files, 18 skill dirs.

* fix(ok-skills): harden the skills.sh surface against shape drift

Four defects a third party can trigger:

- The leaderboard parser scanned loose key:value matches and cut a card at the
  first repeat, so one stray skillId/source/installs key anywhere on the page
  permanently desynchronized the window: later cards paired one skill's name
  with another's repository. Complete-looking, undetectable downstream (the only
  guard is length===0), cached 10 minutes and then kept as last-good forever.
  Now object-scoped via a brace-balanced scan that string contents cannot
  desync; a card needs all three fields on ONE object.

- We rendered a skills.sh URL we could not parse back. The emitter writes
  /<owner>/<repo>/<skill>; the parser demanded the middle segment be the literal
  'skills', which held only because the popular publishers name their repo that.
  Anyone else got a 400 on the link OK itself put in the preview header.

- Preview fell back to the first skill on a name miss while import 404'd on the
  same miss, so the consent surface showed one skill's prose under another's
  name and the user could only install something they had not read. Preview now
  404s exactly as import does; the unnamed browse case is unchanged.

- A rate-limited GitHub fallback rendered as 'No skills found' - the skill does
  not exist - when both backends were merely refusing to answer. Now a 502.

* fix(ok-skills): repair the editor-shell regressions this PR introduced

- Right-clicking the project-root header did nothing. The explicit passthrough
  was deleted, and CollapsibleTrigger renders a <button>, so the interactive-
  control guard preventDefault'd it and Radix skipped opening. The e2e scenarios
  covering it were deleted in the same PR, so nothing caught it.

- The Files toolbar lost its accessible name when ButtonGroup was swapped for a
  layout div (its dom assertion was deleted alongside).

- The config MCP tool had to drop readOnlyHint/idempotentHint because one field
  on it mutated - annotations are per-tool and static, so every plain config
  READ started raising an approval prompt for users who never touch skills. The
  folder-topology verb moves to install, where mutation already lives, and
  config is a pure read again. The verb had zero tests; it has three now.

- A doc literally named 'skills:...' was unopenable: skills:hub is the one tab
  id without a NUL sentinel, so the prefix test swallowed real content and
  routed its hash to the hub. Matched exactly now - nothing mints that id
  anymore, it only survives to prune legacy persisted state.

- One transient session-restore failure disabled tab persistence for the rest
  of the session, silently losing every tab opened afterwards. The no-clobber
  intent is right; the permanence was not.

* chore(ok): drop bun-era keys a merge resurrected in the subtree package.json

top-level workspaces / overrides / patchedDependencies came back in a conflict
resolution; main has none of them. All three are inert under pnpm 10 (it reads
pnpm-workspace.yaml, and pnpm.* rather than the top-level npm keys) and every
entry duplicates the yaml - the 7 patchedDependencies keys were malformed with
doubled versions, so they could never have matched anything. pnpm install
--frozen-lockfile still passes, which is the proof they were dead weight.

* test(ok-skills): pin the redirect gate, the global rename branch, the path regex

Review flagged three Majors as unresolved. Two are already fixed and were cited
at stale line numbers:

- well-known.ts redirects: BOTH call sites (index read and per-file fetch) go
  through fetchWithinOrigin, which sets redirect:'manual' and re-checks https +
  exact origin on every hop with a shared deadline. What was genuinely missing
  was any test of the most security-load-bearing function on the surface, so
  this adds seven: downgrade, cross-origin, link-local, missing Location,
  unresolvable Location, hop cap, and the same-origin hop it SHOULD follow.
- The fork-rename regex already patches the YAML region via detectFmRegion +
  applyPatchToFm rather than a multiline ^name: match.

The third was real but one site, not six: every fs write this PR adds in server
production code is traced except the backup GC in stashDiscardedEdit, whose own
siblings use tracedMkdir/tracedAtomicFs. Now tracedUnlinkSync. The other
untraced additions are all in test files, which the STOP rule exempts.

Both Considers addressed: rewriteSkillRefsAcrossScope now covers the global
scanner (a different root resolution that no test touched), and
isSkillBundleShapedPath gets direct positive/negative cases instead of only
transitive coverage.

* fix(ok-skills): leaderboard parser must tolerate non-JSON Flight payloads

The object-scoped rewrite parsed each candidate object strictly and skipped
what JSON.parse refused. Real Next.js Flight cards routinely carry references
like "icon":$L12, which is not valid JSON - so on the actual skills.sh payload
the parser returned ZERO cards and the popular shelf would have rendered empty
on every load. Verified: a card with one Flight reference yielded nothing.

Falls back to reading the three fields off the object's own source slice,
depth-tracked so a nested {"author":{"source":...}} cannot shadow the card's
field. Still object-scoped, which is the property that stops mispairing: a
stray key elsewhere on the page cannot reach the slice. Bounded at 8KB so a
megabyte-scale wrapper is never depth-mapped.

Three tests pin it: the Flight-reference card, the nested-shadow case, and a
Flight-shaped object still dropping when a card field is absent.

* chore: back out team-skill churn this branch picked up while testing

None of this belongs to the skills-marketplace change - it is fallout from
exercising the feature against the monorepo's own .agents/skills tree:

- .agents/skills/{1on1,animate} were DELETED (9 files). Restored.
- .agents/skills/{analyze,audit-worktrees,agent-dx-feedback}/SKILL.md carried
  editor round-trip churn (blank lines, ~ escaping, frontmatter restructure).
  Restored.
- .agents/skills/code-mode/SKILL.md had real text damage typed into it:
  "between each call." became "between each calls we have many calls."
  Restored.
- .agents/skills/1on1-test-2 was a rename test. Removed.
- .github/skills/* were four install-target symlinks from testing; that
  directory does not exist on main. Removed.

.agents/ and .github/skills/ now match main byte for byte. The workflow edits
under .github/workflows/ are real PR content and are untouched.

* docs: one changeset for release notes, and pack docs that match reality

Nine changesets described this feature in slices, which reads as nine unrelated
entries in the release notes. Collapsed into one that follows what a user
does - find a skill, keep the ones you already have, own it as content, install
a set - with the fixes and internals below that.

Docs:
- Six pack pages still said seeded skills land under .ok/skills/ and are
  symlinked into each editor. The store is retired, and I verified the real
  behavior by running ok seed --pack against a scratch project: the source is
  authored at the project's skill home (.agents/skills when present, else the
  editor root) and COPIED into each set-up editor - distinct inodes, not links.
  Copy is deliberate for seeded packs, which a whole team clones.
- Seven docs had changed only link style (/docs/... to relative .mdx) with no
  content change. Reverted; that churn is not part of this feature.
- projectSkill's docstring still described the symlink default as pointing at
  'the single .ok/skills source' and justified copy by a source the consumer
  would not have. Rewritten for the in-place model, where the real reasons are
  Windows and CI.

* fix(ok-skills): config no longer advertises the verb it stopped accepting

The skillFolders move took the argument off config's schema but left its
DESCRIPTION, its serverUrl dep, and its `folder` output shape behind. An agent
reads the description as the contract, so it would pass skillFolders to config,
have it silently dropped, and believe the folder operation ran. The three
action shapes were good documentation - moved to install rather than deleted.
Removing the dep surfaced a live caller in tools/index.ts.

Closes the four coverage gaps the review named:
- config: the description must not document the verb, and the output must not
  claim a shape it cannot produce.
- install: link (the only action with a required target) and unlink positive
  paths - previously only add-root was exercised.
- The tab-persistence guard is now shouldPersistTabSession(), a pure function
  with all four cases pinned, instead of an inline condition.
- The leaderboard slice cap. The added test also shows the cap is
  fallback-only: an oversized card that IS valid JSON still parses.

Plus an integration test for the degraded search path: a rate-limited GitHub
fallback is a 502, while skills.sh alone failing still degrades to GitHub.

* test(ok-skills): @covers-* tags belong on each test, not the file docstring

md-audit:check requires the tag pair per test() case; putting them once in the
module docstring left both cases untagged and failed the CI lint job. The
sibling integration suites already do it per-test - matched them.

* chore(ok): refresh the ng-anchors catalog test-file count

Adding an integration test file made the committed catalog stale: its
testFileCount said 254, a fresh build says 255, and ng-anchors-catalog.test.ts
deep-equals the two (excluding generatedAt). Regenerated via
enumerate-ng-anchors, which is the catalog's own generator - md-audit:generate
rebuilds the byte-preservation audit instead and does not touch this file.

* test(ok-skills): leaderboard fixture must emit real card OBJECTS

The server-side fixture built bare "key":value runs directly inside a JSON
array - not valid JSON, and a shape RSC Flight cannot produce. It only ever
parsed because the old scanner matched loose key/value pairs anywhere in the
stream, which is the same looseness that let one stray key mispair every later
card. The sibling fixture in core has always used objects; these two encoded
different beliefs about the payload. Corrected the unrealistic one.

* fix(ok-skills): restore the global skill-root mkdir the watcher was doing

My $HOME-pollution fix filtered the watcher's roots to ones whose parent
already exists. That mkdir was load-bearing beyond watching: with a fresh HOME
(every integration test, and a real first run) the global skill roots stopped
being created, and cross-scope moves broke - the project->global source never
materialized, and global projections were left standing.

Caught by skill-cross-scope-move-uninstall: 2 failed with the filter, 4 pass
without it. Reverting the filter; the skill-state path fix in the same commit
is unrelated and stays. The vendor-dir pollution is real but it is the lesser
problem, and the right fix is an mkdir at the write path rather than removing
the one the writes depend on.

* test(ok-skills): lifecycle test still expected the retired .ok/skills path

It hardcoded .ok/skills/<name>/SKILL in three places, so the poll never matched
and timed out at 5s. With the store retired a project skill lands at the
default skill home, which for a fresh contentDir resolves to .claude/skills -
the landing every sibling suite already asserts. Replaced the literals with one
helper that says why.

* test(ok-skills): derive the skill doc name from the server, and give the post-rename re-index a real budget

Two shard1 failures, both 'pollUntil timed out after 5000ms', both passing
locally and failing on CI.

- I had hardcoded .claude/skills after the store retirement. That is not a
  constant: resolveDefaultSkillHomeRel picks .agents/skills, else the first
  existing editor root, else .claude/skills, so the answer depends on what
  exists in the content dir. The test now takes the path from the PUT response
  instead of guessing.

- Both polls wait on the post-rename derived-index refresh, which is async
  (serialized queue + debounce) with no bounded SLA. 5s holds on a quiet laptop
  and not on a loaded sharded runner. Raised those two to 20s; the default
  stays 5s everywhere else.

* fix(ok-skills): projection must never write through a root that aliases the canonical

A cross-scope move destroyed the skill it was moving and returned 200.

Mechanism: with the store retired, a project skill's canonical lives in
.agents/skills — which .claude/skills, .cursor/skills and .codex/skills alias
as FOLDER symlinks. projectSkill then wrote the claude projection onto the
canonical itself: rmSync deleted the real bundle, and the symlink replacing it
resolved to its own path. After that first breach realpathSync throws ELOOP, so
the existing per-destination sameEntry check went blind and every later host
re-destroyed it. Four broken links, five tracked files gone from the worktree.

The old code was not wrong on main - there the canonical lived in .ok/skills,
which is not a host root, so no alias could collapse onto it. Retiring the store
is what made an unguarded write path self-destructive.

isAliasOfCanonicalRoot compares the ROOTS and never touches the destination, so
it still holds once such a cycle exists on disk - unlike sameEntry, which needs
a readable dest. An aliased host is counted as projected rather than dropped:
it reads the canonical directly, so the skill is present there by construction.

Also: move-scope now verifies a readable SKILL.md at the destination before
answering 200. Every step returning without throwing is not evidence the skill
survived, and the success toast is only as true as that check.

* chore(ok-skills): keep the alias guard module-private

Exported it for symmetry with hostSkillsRootEscapes, but nothing outside this
file calls it, so knip fails the lint gate. projectSkill is the only write path
that needs it and the behaviour is covered through that entry point.

* test(ok-skills): make the stuck re-index polls report what they saw

Both polls now time out at 20s, not 5s — so the post-rename re-index is STUCK
on CI, not slow, and my earlier read that this was a budget problem was wrong.
They still pass locally on every run, with and without the ref sweep (A/B'd),
so I cannot reproduce it here.

A bare pollUntil timeout only says 'never became true'. These now capture the
actual tag list, backlink list, and which of the three conditions failed, so
the next CI run is a diagnosis instead of another guess.

* fix(ok-skills): stop swallowing the two failures that silently erase state

Both are the same shape as the move's lying 200 — an error path that discards
data and reports nothing, so the damage is indistinguishable from success.

- reindexMovedProjectSkillDocs: an unreadable file at the relocated path drops
  the OLD index entry and adds no new one, so the skill disappears from tags,
  backlinks and search until something re-indexes it. CI has been failing on
  exactly this state (tags and backlinks both empty after a rename) and the
  swallowed catch is why three rounds of investigation could not tell whether
  the file was missing or the create event was dropped. It now logs the path
  and the underlying error.

- readSkillPlacements: a corrupt ledger falls back to empty with no signal,
  forgetting every recorded placement, and the next write persists the empty
  ledger over the corrupt one. Matches readSkillsLockFile, which already logs
  this exact case.

* fix(ok-skills): report the last silent exit on the rename re-index path

Evidence from CI narrowed it to this branch. The move returns 200 (so no
content-edit error), the reindex block does not throw, and the new
unreadable-file warning never fires — yet the index ends up empty. The only
remaining way out of reindexMovedProjectSkillDocs without doing anything is the
missing-index early return, and it said nothing.

It matters because the file watcher still drops the old doc name on a rename.
Skipping the re-index therefore does not mean 'no change' — it means the skill
leaves tags, backlinks and search with nothing taking its place.

* chore(ok-app): combined-chunks budget 3.7 -> 3.72 MB after merging main

CI measured 4.14 kB over. The merge stacks main's in-doc comments feature
(CommentedDocProvider, PropertyCommentButton, comment queue + margin rail, and
the derived-index onDocumentSettled hook) on top of the skills-marketplace
branch; neither is the single source.

Only the combined budget moved. The main app bundle is unaffected at 395 kB
against its 485 kB limit and CSS at 8.4 kB against 59 kB, so this is lazy-chunk
weight rather than eager. Rationale recorded in BUNDLE-SIZE.md, where main
relocated this history.

* fix(ok-skills): a bundle copy must materialize bytes, not the symlink

Reported: moving a skill project->global and back left every project path
dangling, with the server log showing 'Broken/cyclic symlink at
.agents/skills/<name>'. The move-scope post-condition correctly refused with a
500, but the destruction had already happened.

Cause: cpSync defaults to dereference:false. Once  points a skill at
another location its canonical dir IS a symlink, so the cross-scope copy wrote a
link to the source tree instead of the bytes — and the very next step deletes
that tree. In a project whose .claude/.cursor/.codex roots are folder aliases of
.agents/skills, one dangling entry surfaces at four paths at once, and the three
real roots that link to it dangle too.

Both bundle-materializing copies now dereference: the cross-scope move, and
projection copy mode (a copy that tracks its source is a link, which is the one
thing copy mode exists not to be).

Pinned by a test that builds the reported shape — canonical is a symlink, copy
must survive deleting the original tree. It fails without the fix.

* fix(ok-skills): a cross-scope move relocates the real tree, not a pointer to it

Second half of the symlinked-source bug. resolveSkillDirForRead answers 'where
is this skill found' by root precedence and never realpaths, so once `source`
points a skill at another location the elected canonical is itself a symlink.
The move then treated that pointer as the thing to relocate.

The move now resolves its source once, up front, so the copy, the delete and the
marker all agree on where the bytes actually are — rather than each rediscovering
it and one of them getting a different answer. dereference on the copy (previous
commit) makes it safe; this makes it say what it means.

Covered end-to-end by a new integration test that drives the real
/api/skill/move-scope endpoint with a symlinked canonical, and asserts the
destination survives deleting the original tree. The existing cross-scope suite
never caught this because its helper does put-then-delete rather than calling
the move endpoint at all.

* fix(ok-skills): sweep the remaining copy sites that could materialize a pointer

I fixed the two paths my repro proved and said the other cpSync sites needed a
sweep. Review then found two of them; this does the sweep properly.

Seven more copies now dereference — every one is the copy branch of an explicit
link-vs-copy choice, where the whole point of the else is 'not a link':
projectInPlaceSkill's materialize, the placement refresh, the two install-path
link/copy branches, the link-to-copy convert, the hub copy, and skill-duplicate.

Deliberately NOT changed: moveDir in skill-migrate is a rename with a
cross-device copy fallback — renaming a link should keep it a link, so
dereferencing there would materialize where the original merely pointed. The
bundled-asset copies (skill-install, seed) read from the app bundle, which has
no symlinks. The timeline stash copy is a backup; dereferencing changes what a
backup means, so it wants its own decision rather than riding along here.

* fix(ok-skills): cross-scope move relocates the real tree, not just the elected pointer

The skill-root election answers where a skill is FOUND, by root precedence,
and never realpaths. When the elected occurrence is a symlink (the source verb
pointed the skill at another location), applySkillDelete removed only that
pointer and left the real bytes at the origin, so the skill reappeared in the
source scope after a move that reported success.

Delete the resolved tree too, bounded to the scope's own base so an
outside-base source directory (the user's own, unscanned) is left alone.

* fix(ok-skills): refresh the in-place skill allow-list before re-indexing a rename

In-place skill admission is a registry allow-list of exact bundle dirs, not a
path prefix, and it only refreshes on an ignore-pattern rebuild. A rename moved
the bundle to a dir the allow-list had never seen, so the destination doc read
as excluded — and an unadmitted rename target makes the derived index delete
the OLD entry and the NEW one both, dropping the skill out of tags, backlinks
and search entirely.

macOS hid this: the file watcher refreshed the allow-list inside the test's
poll window. The Linux CI runner never did, so shard1 failed deterministically
on the two post-rename re-index tests.

* chore(ok): regenerate Lingui catalogs after merging main

Main's ThreadView change reordered the extracted messages, so the catalogs
committed before the merge no longer matched what the extractor produces from
the merged tree. CI extracts from the merge commit, which is why this only
showed up there.

---------

GitOrigin-RevId: f663d5fbe9cf3ea79331b52f303cf3650fc46165
2026-07-31 02:10:24 +00:00
..