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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user