From 8566e23f495673ec0b5469000a49681e4f93a83f Mon Sep 17 00:00:00 2001 From: Myfro9 <36755290+Myfro9@users.noreply.github.com> Date: Mon, 7 Sep 2026 16:29:20 +0800 Subject: [PATCH] =?UTF-8?q?tools-sync:=20=E4=BF=AE=E5=A4=8D=E4=BC=9A?= =?UTF-8?q?=E5=88=A0=E6=8E=89=E5=AE=BF=E4=B8=BB=E8=87=AA=E8=A3=85=20skill?= =?UTF-8?q?=20=E7=9A=84=20registry=20=E5=AF=B9=E8=B4=A6=E7=BC=BA=E9=99=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本次同步实跑触发了数据丢失:orca-cli 的物理副本在 ~\.agents\skills(Orca 就是 这样装的——真目录放 .agents,再把每个工具链过去,本机 8 个工具全指向它), 而主副本里的同名条目本身是指向它的 junction。对账循环从不检查"主副本条目是不是 链接",于是 fingerprint 比对经链接解析到同一份内容判为"identical",删掉 registry 副本并建 junction → 主副本与 registry 互指成环,唯一的真副本没了。 orchestration 走 INGEST 分支,则把 Orca 的布局反了过来。 两者均已用 `orca skills install --skill ` 恢复为 Orca 自己的布局。 修复三处: - 对账循环:主副本条目本身是链接时,判定该 skill 归宿主所有,计入 foreign 跳过。 - INGEST:新增 Get-SkillDirLinkers,若 ~\.* 下其他工具的 skills 目录有链接指向 这个目录,说明它是宿主自装 skill 的物理家,不搬进主副本。 - 镜像阶段:跳过本身是链接的主副本条目,否则 Codex 会把同一 skill 注册两遍 (.agents 与 .codex 都在扫)。 回归测试新增 hostowned 夹具(真目录在 registry、主副本是链接):在修复前的脚本上 以"real dir was collapsed into a link"失败,修复后通过。 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01438Zbcx49e4o46uSA71mFF --- tools-sync/scripts/sync_repos.ps1 | 47 ++++++++++++++++++- .../tests/test_sync_repos_registries.ps1 | 15 ++++++ 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/tools-sync/scripts/sync_repos.ps1 b/tools-sync/scripts/sync_repos.ps1 index c548545..19968cc 100644 --- a/tools-sync/scripts/sync_repos.ps1 +++ b/tools-sync/scripts/sync_repos.ps1 @@ -117,6 +117,28 @@ function Get-DirFingerprint([string]$Path) { return (($lines | Sort-Object) -join "`n") } +# Agent tools each keep their own skills directory and a host installer may pick any one of them +# as a skill's physical home, linking the rest at it. Finding those linkers is how we tell a +# host-installed skill from one authored on the Codex side. +function Get-SkillDirLinkers([string]$TargetPath) { + $roots = @() + foreach ($d in Get-ChildItem $env:USERPROFILE -Directory -Force -ErrorAction SilentlyContinue | + Where-Object { $_.Name -like ".*" }) { + foreach ($sub in @("skills", "agent\skills")) { + $cand = Join-Path $d.FullName $sub + if (Test-Path $cand) { $roots += $cand } + } + } + $hits = @() + foreach ($r in $roots) { + if (Test-UnderPath $TargetPath $r) { continue } # the home itself + $link = Join-Path $r (Split-Path $TargetPath -Leaf) + $item = Get-Item -LiteralPath $link -Force -ErrorAction SilentlyContinue + if ($item -and (Test-IsLink $item) -and ((Get-LinkTarget $item) -eq $TargetPath)) { $hits += $r } + } + return $hits +} + function New-SkillJunction([string]$LinkPath, [string]$TargetPath, [string]$Why) { if ($DryRun) { Write-Host " (dry-run) would link $LinkPath -> $TargetPath [$Why]"; return } New-Item -ItemType Junction -Path $LinkPath -Target $TargetPath | Out-Null @@ -272,6 +294,17 @@ foreach ($reg in $registries) { $name = Split-Path $copyPath -Leaf $primaryPath = Join-Path $ClaudeSkills $name if (-not (Test-Path $copyPath)) { continue } # already resolved earlier this run + + # The primary entry is itself a link into a registry: this skill's physical home is the + # registry, and the host installed it that way round (Orca does exactly this - it puts the + # real directory in ~\.agents\skills and links every tool at it). Collapsing the registry + # copy here would point the link at itself and DELETE the only copy. Leave it to its owner. + $primaryItem = Get-Item -LiteralPath $primaryPath -Force -ErrorAction SilentlyContinue + if ($primaryItem -and (Test-IsLink $primaryItem)) { + $foreign++ + continue + } + $repairLinks += $copyPath # --- real directory holding a skill --- @@ -287,6 +320,15 @@ foreach ($reg in $registries) { Write-Host " registry-only skill NOT ingested (-NoIngest): $name" continue } + # Other agent tools linking at this directory mean it is a host-installed skill's + # physical home, not a skill authored here. Moving it inverts the host's layout and + # its own updater then fights us for the location. + $linkers = Get-SkillDirLinkers $copyPath + if ($linkers) { + $script:Notes += "$name lives in $copyPath and $($linkers.Count) other tool registr$(if ($linkers.Count -eq 1) { 'y links' } else { 'ies link' }) at it; treated as host-installed and left alone." + Write-Host " host-installed skill (linked from $($linkers.Count) other tool(s)), not ingested: $name" + continue + } Write-Host " INGEST registry-only skill: $name ($copyPath -> $primaryPath)" if (-not $DryRun) { Move-Item -LiteralPath $copyPath -Destination $primaryPath } $script:Ingested += $name @@ -332,7 +374,10 @@ if ($MirrorRegistry) { if (-not $DryRun) { Remove-Item -Recurse -Force $mirrorGit } } - $primarySkills = Get-ChildItem $ClaudeSkills -Directory | Where-Object { $_.Name -notlike ".*" } + # A primary entry that is itself a link belongs to whichever host installed it (its real home + # is a registry the host already exposes to Codex). Mirroring it would register it twice. + $primarySkills = Get-ChildItem $ClaudeSkills -Directory -Force | + Where-Object { $_.Name -notlike ".*" -and -not (Test-IsLink $_) } foreach ($d in $primarySkills) { $link = Join-Path $MirrorRegistry $d.Name if (Test-Path $link) { continue } diff --git a/tools-sync/tests/test_sync_repos_registries.ps1 b/tools-sync/tests/test_sync_repos_registries.ps1 index 05bb385..a8f1e66 100644 --- a/tools-sync/tests/test_sync_repos_registries.ps1 +++ b/tools-sync/tests/test_sync_repos_registries.ps1 @@ -27,6 +27,12 @@ try { # A link into the other registry rather than the primary: the drift case. New-Item -ItemType Junction -Path (Join-Path $codex "shared") -Target (Join-Path $agents "shared") | Out-Null + # Host-installed shape: the real directory lives in a registry and the PRIMARY is the link + # (Orca installs this way). Collapsing the registry copy here would make the link point at + # itself and delete the only copy - the reconciler must recognise it and keep its hands off. + New-Skill (Join-Path $agents "hostowned") "installed by the host" + New-Item -ItemType Junction -Path (Join-Path $primary "hostowned") -Target (Join-Path $agents "hostowned") | Out-Null + # A local bare repo stands in for origin so Phase 1 completes and Phase 2 actually runs. $origin = Join-Path $root "origin.git" & git init --quiet --bare $origin @@ -57,6 +63,15 @@ try { if ((Get-Content -Raw (Join-Path $primary "forked\SKILL.md")) -notmatch 'primary body') { throw "primary was overwritten by a conflict`n$output" } if ($output -notmatch 'not a skill dir') { throw "directory without SKILL.md not reported`n$output" } + + # The host-installed skill must survive intact, with the primary still a link at the registry. + $hostReal = Get-Item -Force (Join-Path $agents "hostowned") + if ($hostReal.LinkType -eq "Junction") { throw "host-installed skill's real dir was collapsed into a link`n$output" } + if ((Get-Content -Raw (Join-Path $agents "hostowned\SKILL.md")) -notmatch 'installed by the host') { throw "host-installed skill's content was lost`n$output" } + $hostPrimary = Get-Item -Force (Join-Path $primary "hostowned") + if (($hostPrimary.Target | Select-Object -First 1) -ne (Join-Path $agents "hostowned")) { throw "primary link to the host-installed skill was repointed`n$output" } + # Mirroring it into the registry Codex scans would register the same skill twice. + if (Test-Path (Join-Path $codex "hostowned")) { throw "host-installed skill was mirrored, double-registering it`n$output" } # Mirror registry must expose every primary skill, including the freshly ingested one. foreach ($name in @("shared", "forked", "codexborn")) { if (-not (Test-Path (Join-Path $codex "$name\SKILL.md"))) { throw "mirror missing $name`n$output" }