feat(tools): режим -Remove у sync-agent-assets — снятие junction'ов перед checkout

При переключении на ветку или коммит без plugin/skills junction'ы навыков
повисают битыми, и git checkout падает на середине: часть рабочего дерева
уже удалена, а HEAD остаётся на прежней ветке.

Remove-AgentSkillLink удаляет сам reparse point через Directory.Delete
(без -Recurse), поэтому в target не заходит и содержимое plugin/skills не
трогает. Junction опознаётся по атрибуту ReparsePoint, а не по резолву
target — иначе режим не работал бы ровно в том случае, ради которого сделан.
Настоящий каталог не удаляется, а возвращает Skipped.

Режимы скрипта разведены parameter set'ами: -Remove -Check вместе теперь
дают ошибку разбора параметров вместо тихого выигрыша одного из ключей.

Покрыто пятью кейсами в tools/tests/AgentAssets.Tests.ps1 (включая junction
с исчезнувшим источником); порядок действий и восстановление после падения
чекаута описаны в CLAUDE.md.
This commit is contained in:
2026-07-31 08:24:26 +03:00
parent 5c74a6f0ab
commit e3228abb26
4 changed files with 117 additions and 4 deletions
+15
View File
@@ -38,6 +38,21 @@ Where to look (single source of truth — do **not** duplicate these lists here)
Principle: **MCP = translating SDK capabilities into general tools** (not task-specific). On top of MCP — skill **`.claude/skills/kompas-3d/`** with a methodology (playbooks, heuristics, validated in `usecases/`). Layout: `usecases/` (in .gitignore) is the proving ground; proven techniques are promoted to the skill. A separate skill **`.claude/skills/kompas-fdm-design/`** is a DFM methodology layer for FDM/FFF 3D-printing (overhang rules, wall thickness, teardrop holes, fits/clearances, orientation for strength, elephant foot, bosses/inserts/snap-fits) with a lightweight geometry audit using existing inspection tools; it works on top of `kompas-3d` (the build layer) and exports via `export_step` — no slicer integration.
**Skill layout / sync** (`tools/sync-agent-assets.ps1`, module `tools/AgentAssets.psm1`): source of truth is `plugin/skills/{kompas-3d,kompas-fdm-design}`; `.claude/skills/<name>` and `.agents/skills/<name>` are **junctions** onto it created by the script (their paths are pinpointed in `.gitignore` — git sees a junction as a plain directory). Modes are mutually exclusive parameter sets (`-Remove -Check` together is a parameter-binding error):
- no flags — link (`Created`/`AlreadyLinked`)
- `-Check` — verify, prints `БИТО` and exits 1 on drift
- `-AllowCopy` — fall back to a real copy (`Copied`) if a junction can't be created
- `-Remove` — unlink (`Removed`/`Missing`/`Skipped`)
**Gotcha:** switching branch/commit to one predating `plugin/skills` leaves the junctions dangling and `git checkout` fails mid-way (`unable to create file .claude/skills/kompas-fdm-design/SKILL.md: No such file or directory`) — part of the worktree is already deleted while HEAD stays on the old branch. Recover with `git ls-files --deleted -z | xargs -0 git restore --`.
Correct order:
1. `pwsh -NoProfile -File tools/sync-agent-assets.ps1 -Remove`
2. `git checkout <ref>`
3. `pwsh -NoProfile -File tools/sync-agent-assets.ps1` (re-link, if `plugin/skills` exists on the new ref)
`-Remove` is safe because it deletes the reparse point itself via `[System.IO.Directory]::Delete` (no `-Recurse`) — never walks into the target, so `plugin/skills` content is untouched, and a real (non-junction) directory is left alone (`Skipped`); it detects a junction by the `ReparsePoint` attribute, not by resolving the target, which is exactly why it still works once the target is gone. No `post-checkout` hook on purpose — it fires after checkout already broke, too late to help; step 3 covers the re-link anyway. Tests: `Describe 'Remove-AgentSkillLink'` (5 cases) in `tools/tests/AgentAssets.Tests.ps1`, run via `pwsh -NoProfile -File tools/tests/run-ps-tests.ps1` (34 green).
**Delegation subagents** (`.claude/agents/`, dispatched via the `Agent` tool to keep heavy work off Opus's context — system prompt lives in the agent definition, so pass only the variable part as the prompt):
- **`kompas-sdk-research`** (model: **Haiku**, read-only — MCP `kompas-sdk` tools only): finds an interface/method/enum/constant signature in the SDK knowledge base and returns a compressed summary. Dispatch it for **any** SDK lookup — it holds the only access to the base, which no longer lives in this repo; a wrong answer is cheap — Opus re-verifies against `libs/kompas-interop/*.dll` by reflection.
- **`docs-maintainer`** (model: **Sonnet**, `Read`/`Edit`/`Write`/`Glob`/`Grep`): syncs `README.md`/`CLAUDE.md`/`docs/ARCHITECTURE.md`/`docs/OPEN_QUESTIONS.md`/`docs/presentation.html` with code changes from a change summary you provide. Dispatch it for doc updates instead of editing docs by hand. (Both were skills before — converted to native subagents; the model split reflects task type: bounded retrieval → Haiku, judgement-heavy editing → Sonnet.)
+28 -1
View File
@@ -66,4 +66,31 @@ function Sync-AgentSkillLink {
}
}
Export-ModuleMember -Function Test-AgentSkillLink, Sync-AgentSkillLink
<#
.SYNOPSIS
Снимает junction по LinkPath. Возвращает Removed | Missing | Skipped.
Настоящие каталоги не трогает, в target не заходит.
#>
function Remove-AgentSkillLink {
[CmdletBinding()]
param(
[Parameter(Mandatory)] [string] $LinkPath
)
$item = Get-Item -LiteralPath $LinkPath -Force -ErrorAction SilentlyContinue
if ($null -eq $item) { return 'Missing' }
# Проверяем именно атрибут, а не LinkType: junction с удалённым target
# (ровно наш случай после checkout без plugin/skills) остаётся reparse point,
# но полагаться на резолв target тут нельзя.
$isReparse = $item.Attributes.HasFlag([System.IO.FileAttributes]::ReparsePoint)
if (-not ($item -is [System.IO.DirectoryInfo]) -or -not $isReparse) {
return 'Skipped'
}
# Удаляем сам reparse point: без -Recurse содержимое target не затрагивается.
[System.IO.Directory]::Delete($LinkPath)
return 'Removed'
}
Export-ModuleMember -Function Test-AgentSkillLink, Sync-AgentSkillLink, Remove-AgentSkillLink
+21 -2
View File
@@ -1,8 +1,16 @@
#requires -Version 5.1
[CmdletBinding()]
[CmdletBinding(DefaultParameterSetName = 'Sync')]
param(
[Parameter(ParameterSetName = 'Check')]
[switch] $Check,
[switch] $AllowCopy
[Parameter(ParameterSetName = 'Sync')]
[switch] $AllowCopy,
# Снимает junction'ы перед переключением на ветку/коммит без plugin/skills:
# иначе git checkout падает на середине, не сумев создать файлы внутри битой ссылки.
[Parameter(ParameterSetName = 'Remove')]
[switch] $Remove
)
Set-StrictMode -Version Latest
@@ -23,6 +31,17 @@ foreach ($root in $targetRoots) {
$source = Join-Path $repoRoot "plugin\skills\$skill"
$link = Join-Path $root $skill
if ($Remove) {
$state = Remove-AgentSkillLink -LinkPath $link
if ($state -eq 'Skipped') {
Write-Host "Skipped $link (настоящий каталог, не тронут)"
}
else {
Write-Host "$state $link"
}
continue
}
if ($Check) {
if (Test-AgentSkillLink -SourcePath $source -LinkPath $link) {
Write-Host "ok $link"
+53 -1
View File
@@ -1,4 +1,4 @@
BeforeAll {
BeforeAll {
Import-Module (Join-Path $PSScriptRoot '..' 'AgentAssets.psm1') -Force
function New-TempTree {
@@ -95,3 +95,55 @@ Describe 'Sync-AgentSkillLink' {
} finally { Remove-Item -LiteralPath $t.Root -Recurse -Force -ErrorAction SilentlyContinue }
}
}
Describe 'Remove-AgentSkillLink' {
It 'снимает junction, оставляя исходный навык нетронутым' {
$t = New-TempTree
try {
Sync-AgentSkillLink -SourcePath $t.Source -LinkPath $t.Link | Out-Null
Remove-AgentSkillLink -LinkPath $t.Link | Should -Be 'Removed'
Test-Path -LiteralPath $t.Link | Should -BeFalse
Get-Content -LiteralPath (Join-Path $t.Source 'SKILL.md') | Should -Be 'demo'
} finally { Remove-Item -LiteralPath $t.Root -Recurse -Force -ErrorAction SilentlyContinue }
}
It 'снимает junction с исчезнувшим источником (ветка без plugin/skills)' {
$t = New-TempTree
try {
Sync-AgentSkillLink -SourcePath $t.Source -LinkPath $t.Link | Out-Null
Remove-Item -LiteralPath $t.Source -Recurse -Force
Remove-AgentSkillLink -LinkPath $t.Link | Should -Be 'Removed'
Test-Path -LiteralPath $t.Link | Should -BeFalse
} finally { Remove-Item -LiteralPath $t.Root -Recurse -Force -ErrorAction SilentlyContinue }
}
It 'не трогает настоящий каталог' {
$t = New-TempTree
try {
New-Item -ItemType Directory -Path $t.Link -Force | Out-Null
Set-Content -LiteralPath (Join-Path $t.Link 'mine.md') -Value 'ручное' -Encoding utf8
Remove-AgentSkillLink -LinkPath $t.Link | Should -Be 'Skipped'
Get-Content -LiteralPath (Join-Path $t.Link 'mine.md') | Should -Be 'ручное'
} finally { Remove-Item -LiteralPath $t.Root -Recurse -Force -ErrorAction SilentlyContinue }
}
It 'на отсутствующем пути сообщает Missing и не падает' {
$t = New-TempTree
try {
Remove-AgentSkillLink -LinkPath $t.Link | Should -Be 'Missing'
} finally { Remove-Item -LiteralPath $t.Root -Recurse -Force -ErrorAction SilentlyContinue }
}
It 'идемпотентен: повторное снятие даёт Missing' {
$t = New-TempTree
try {
Sync-AgentSkillLink -SourcePath $t.Source -LinkPath $t.Link | Out-Null
Remove-AgentSkillLink -LinkPath $t.Link | Should -Be 'Removed'
Remove-AgentSkillLink -LinkPath $t.Link | Should -Be 'Missing'
} finally { Remove-Item -LiteralPath $t.Root -Recurse -Force -ErrorAction SilentlyContinue }
}
}