aid-installer 1.1.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -47,6 +47,10 @@
47
47
 
48
48
  Set-StrictMode -Version Latest
49
49
 
50
+ # Enable TLS 1.2 for HTTPS. Windows PowerShell 5.1 (.NET Framework) can default to
51
+ # SSL3/TLS1.0, which GitHub/npm/pypi reject -> downloads fail. Harmless on PS7/.NET Core.
52
+ try { [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 } catch {}
53
+
50
54
  # Guard against being imported more than once.
51
55
  # Use Get-Variable with -ErrorAction SilentlyContinue to avoid strict-mode failure on first load.
52
56
  $_aidLoadedVar = Get-Variable -Name '_AID_INSTALL_CORE_LOADED' -Scope Global -ErrorAction SilentlyContinue
@@ -678,7 +682,7 @@ function Read-ManifestToolPaths {
678
682
  try {
679
683
  $data = Get-Content -LiteralPath $ManifestPath -Raw | ConvertFrom-Json
680
684
  $toolData = if ($data.tools -and ($data.tools.PSObject.Properties.Name -contains $Tool)) { $data.tools.$Tool } else { $null }
681
- if ($toolData -and $toolData.paths) {
685
+ if ($toolData -and $toolData.PSObject.Properties['paths'] -and $toolData.paths) {
682
686
  return @($toolData.paths)
683
687
  }
684
688
  } catch {}
@@ -693,7 +697,7 @@ function Read-ManifestToolVersion {
693
697
  try {
694
698
  $data = Get-Content -LiteralPath $ManifestPath -Raw | ConvertFrom-Json
695
699
  $toolData = if ($data.tools -and ($data.tools.PSObject.Properties.Name -contains $Tool)) { $data.tools.$Tool } else { $null }
696
- if ($toolData -and $toolData.version) { return $toolData.version }
700
+ if ($toolData -and $toolData.PSObject.Properties['version'] -and $toolData.version) { return [string]$toolData.version }
697
701
  } catch {}
698
702
  return ''
699
703
  }
@@ -706,9 +710,15 @@ function Read-ManifestRootAgent {
706
710
  try {
707
711
  $data = Get-Content -LiteralPath $ManifestPath -Raw | ConvertFrom-Json
708
712
  $toolData = if ($data.tools -and ($data.tools.PSObject.Properties.Name -contains $Tool)) { $data.tools.$Tool } else { $null }
709
- if ($toolData -and $toolData.root_agent_files) {
710
- foreach ($entry in $toolData.root_agent_files) {
711
- if ($entry.path -eq $FileName) { return $entry.sha256 }
713
+ # Guard root_agent_files: absent on old-format manifests; PSObject.Properties['key'] is
714
+ # safe under Set-StrictMode -Version Latest unlike direct property access.
715
+ $raf = if ($toolData -and $toolData.PSObject.Properties['root_agent_files']) { $toolData.root_agent_files } else { $null }
716
+ if ($raf) {
717
+ foreach ($entry in $raf) {
718
+ if ($entry.PSObject.Properties['path'] -and $entry.path -eq $FileName) {
719
+ $sha = if ($entry.PSObject.Properties['sha256']) { $entry.sha256 } else { '' }
720
+ return $sha
721
+ }
712
722
  }
713
723
  }
714
724
  } catch {}
@@ -723,9 +733,10 @@ function Read-ManifestRootAgentStatus {
723
733
  try {
724
734
  $data = Get-Content -LiteralPath $ManifestPath -Raw | ConvertFrom-Json
725
735
  $toolData = if ($data.tools -and ($data.tools.PSObject.Properties.Name -contains $Tool)) { $data.tools.$Tool } else { $null }
726
- if ($toolData -and $toolData.root_agent_files) {
727
- foreach ($entry in $toolData.root_agent_files) {
728
- if ($entry.path -eq $FileName) {
736
+ $raf = if ($toolData -and $toolData.PSObject.Properties['root_agent_files']) { $toolData.root_agent_files } else { $null }
737
+ if ($raf) {
738
+ foreach ($entry in $raf) {
739
+ if ($entry.PSObject.Properties['path'] -and $entry.path -eq $FileName) {
729
740
  if ($entry.PSObject.Properties['status']) { return $entry.status }
730
741
  return 'owned'
731
742
  }
@@ -880,8 +891,11 @@ function Write-AidManifest {
880
891
  }
881
892
 
882
893
  # Top-level installed_at: preserve existing.
894
+ # Guard via PSObject.Properties['key'] -- Set-StrictMode -Version Latest (active in this module)
895
+ # throws PropertyNotFoundException on direct access of absent properties. Old-format manifests
896
+ # (schema:1) have no root-level installed_at; new-format ones (manifest_version:1) do.
883
897
  $topInstalledAt = $now
884
- if ($existingData -and $existingData.installed_at) {
898
+ if ($existingData -and $existingData.PSObject.Properties['installed_at'] -and $existingData.installed_at) {
885
899
  $topInstalledAt = $existingData.installed_at
886
900
  }
887
901
 
@@ -895,17 +909,22 @@ function Write-AidManifest {
895
909
  $tid = $_.Name
896
910
  if ($tid -ne $Tool) {
897
911
  $t = $_.Value
898
- $tP = if ($t.paths) { [System.Collections.Generic.List[string]]($t.paths) } else { [System.Collections.Generic.List[string]]::new() }
912
+ # Guard per-tool properties: old manifests lack root_agent_files; use
913
+ # PSObject.Properties['key'] check before direct access (StrictMode-safe).
914
+ $tP = if ($t.PSObject.Properties['paths'] -and $t.paths) { [System.Collections.Generic.List[string]]($t.paths) } else { [System.Collections.Generic.List[string]]::new() }
899
915
  $tR = [System.Collections.Generic.List[hashtable]]::new()
900
- if ($t.root_agent_files) {
901
- foreach ($e in $t.root_agent_files) {
916
+ $tRaf = if ($t.PSObject.Properties['root_agent_files']) { $t.root_agent_files } else { $null }
917
+ if ($tRaf) {
918
+ foreach ($e in $tRaf) {
919
+ $ePath = if ($e.PSObject.Properties['path']) { $e.path } else { '' }
920
+ $eSha256 = if ($e.PSObject.Properties['sha256']) { $e.sha256 } else { '' }
902
921
  $st = if ($e.PSObject.Properties['status']) { $e.status } else { 'owned' }
903
- $tR.Add(@{ path = $e.path; sha256 = $e.sha256; status = $st })
922
+ if ($ePath) { $tR.Add(@{ path = $ePath; sha256 = $eSha256; status = $st }) }
904
923
  }
905
924
  }
906
925
  $toolsMap[$tid] = @{
907
- Version = if ($t.version) { $t.version } else { '' }
908
- InstalledAt = if ($t.installed_at) { $t.installed_at } else { $now }
926
+ Version = if ($t.PSObject.Properties['version'] -and $t.version) { [string]$t.version } else { '' }
927
+ InstalledAt = if ($t.PSObject.Properties['installed_at'] -and $t.installed_at) { $t.installed_at } else { $now }
909
928
  Paths = $tP
910
929
  RootAgentFiles = $tR
911
930
  }
@@ -921,14 +940,14 @@ function Write-AidManifest {
921
940
 
922
941
  # tool installed_at: preserve existing.
923
942
  $toolInstalledAt = $now
924
- if ($existingTool -and $existingTool.installed_at) {
943
+ if ($existingTool -and $existingTool.PSObject.Properties['installed_at'] -and $existingTool.installed_at) {
925
944
  $toolInstalledAt = $existingTool.installed_at
926
945
  }
927
946
 
928
947
  # De-duplicate paths (union, preserving order: existing first, then new).
929
948
  $seenPaths = [System.Collections.Generic.HashSet[string]]::new()
930
949
  $mergedPaths = [System.Collections.Generic.List[string]]::new()
931
- if ($existingTool -and $existingTool.paths) {
950
+ if ($existingTool -and $existingTool.PSObject.Properties['paths'] -and $existingTool.paths) {
932
951
  foreach ($p in $existingTool.paths) {
933
952
  if ($seenPaths.Add($p)) { $mergedPaths.Add($p) }
934
953
  }
@@ -939,10 +958,13 @@ function Write-AidManifest {
939
958
 
940
959
  # Merge root_agent_files: update or add per path.
941
960
  $rafMap = [System.Collections.Specialized.OrderedDictionary]::new()
942
- if ($existingTool -and $existingTool.root_agent_files) {
943
- foreach ($e in $existingTool.root_agent_files) {
961
+ $existingRaf = if ($existingTool -and $existingTool.PSObject.Properties['root_agent_files']) { $existingTool.root_agent_files } else { $null }
962
+ if ($existingRaf) {
963
+ foreach ($e in $existingRaf) {
964
+ $ePath = if ($e.PSObject.Properties['path']) { $e.path } else { '' }
965
+ $eSha256 = if ($e.PSObject.Properties['sha256']) { $e.sha256 } else { '' }
944
966
  $st = if ($e.PSObject.Properties['status']) { $e.status } else { 'owned' }
945
- $rafMap[$e.path] = @{ path = $e.path; sha256 = $e.sha256; status = $st }
967
+ if ($ePath) { $rafMap[$ePath] = @{ path = $ePath; sha256 = $eSha256; status = $st } }
946
968
  }
947
969
  }
948
970
  foreach ($entry in $RootEntries) {
@@ -995,23 +1017,27 @@ function Remove-ManifestTool {
995
1017
  }
996
1018
 
997
1019
  # Build tools map without the target tool.
1020
+ # Guard all per-tool property accesses via PSObject.Properties['key'] (StrictMode-safe).
998
1021
  $toolsMap = [System.Collections.Specialized.OrderedDictionary]::new()
999
1022
  if ($data.tools) {
1000
1023
  $data.tools.PSObject.Properties | ForEach-Object {
1001
1024
  $tid = $_.Name
1002
1025
  if ($tid -ne $Tool) {
1003
1026
  $t = $_.Value
1004
- $tP = if ($t.paths) { [System.Collections.Generic.List[string]]($t.paths) } else { [System.Collections.Generic.List[string]]::new() }
1027
+ $tP = if ($t.PSObject.Properties['paths'] -and $t.paths) { [System.Collections.Generic.List[string]]($t.paths) } else { [System.Collections.Generic.List[string]]::new() }
1005
1028
  $tR = [System.Collections.Generic.List[hashtable]]::new()
1006
- if ($t.root_agent_files) {
1007
- foreach ($e in $t.root_agent_files) {
1029
+ $tRaf = if ($t.PSObject.Properties['root_agent_files']) { $t.root_agent_files } else { $null }
1030
+ if ($tRaf) {
1031
+ foreach ($e in $tRaf) {
1032
+ $ePath = if ($e.PSObject.Properties['path']) { $e.path } else { '' }
1033
+ $eSha256 = if ($e.PSObject.Properties['sha256']) { $e.sha256 } else { '' }
1008
1034
  $st = if ($e.PSObject.Properties['status']) { $e.status } else { 'owned' }
1009
- $tR.Add(@{ path = $e.path; sha256 = $e.sha256; status = $st })
1035
+ if ($ePath) { $tR.Add(@{ path = $ePath; sha256 = $eSha256; status = $st }) }
1010
1036
  }
1011
1037
  }
1012
1038
  $toolsMap[$tid] = @{
1013
- Version = if ($t.version) { $t.version } else { '' }
1014
- InstalledAt = if ($t.installed_at) { $t.installed_at } else { '' }
1039
+ Version = if ($t.PSObject.Properties['version'] -and $t.version) { [string]$t.version } else { '' }
1040
+ InstalledAt = if ($t.PSObject.Properties['installed_at'] -and $t.installed_at) { $t.installed_at } else { '' }
1015
1041
  Paths = $tP
1016
1042
  RootAgentFiles = $tR
1017
1043
  }
@@ -1024,8 +1050,8 @@ function Remove-ManifestTool {
1024
1050
  return
1025
1051
  }
1026
1052
 
1027
- $topIat = if ($data.installed_at) { $data.installed_at } else { ([System.DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ssZ')) }
1028
- $topVer = if ($data.aid_version) { $data.aid_version } else { '0.0.0' }
1053
+ $topIat = if ($data.PSObject.Properties['installed_at'] -and $data.installed_at) { $data.installed_at } else { ([System.DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ssZ')) }
1054
+ $topVer = if ($data.PSObject.Properties['aid_version'] -and $data.aid_version) { $data.aid_version } else { '0.0.0' }
1029
1055
 
1030
1056
  $json = script:Build-ManifestJson -TopInstalledAt $topIat -TopVersion $topVer -ToolsMap $toolsMap
1031
1057
 
@@ -1099,7 +1125,7 @@ function Invoke-AidProvisionSharedStateHome {
1099
1125
  "schema: 1",
1100
1126
  "projects:"
1101
1127
  )
1102
- Set-Content -LiteralPath $tmp -Value $seedLines -Encoding utf8NoBOM -ErrorAction Stop
1128
+ [System.IO.File]::WriteAllText($tmp, (($seedLines) -join "`n") + "`n", [System.Text.UTF8Encoding]::new($false))
1103
1129
  Move-Item -LiteralPath $tmp -Destination $reg -Force -ErrorAction Stop
1104
1130
  } catch {
1105
1131
  Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue
@@ -1211,15 +1237,20 @@ function Invoke-PruneToolDirs {
1211
1237
  # Rule (d): prune now-empty subdirs (deepest first, skip the root itself).
1212
1238
  $subdirs = @(Get-ChildItem -LiteralPath $ADir -Recurse -Directory -ErrorAction SilentlyContinue)
1213
1239
  # Sort deepest first (longest path first by ordinal).
1214
- $subdirPaths = [string[]]($subdirs | ForEach-Object { $_.FullName })
1215
- [System.Array]::Sort($subdirPaths, [System.StringComparer]::Ordinal)
1216
- [System.Array]::Reverse($subdirPaths)
1217
- foreach ($dp in $subdirPaths) {
1218
- if (Test-Path $dp -PathType Container) {
1219
- $rem = @(Get-ChildItem -LiteralPath $dp -ErrorAction SilentlyContinue) | Select-Object -First 1
1220
- if (-not $rem) {
1221
- Remove-Item -LiteralPath $dp -Force
1222
- if ($AidVerbose) { Write-Host "Pruned dir: $dp" }
1240
+ # Guard: pipeline over an empty @() returns $null on PowerShell; [System.Array]::Sort($null)
1241
+ # throws "Value cannot be null" (reproduces on pwsh when the dir has no subdirectories,
1242
+ # e.g. cursor/antigravity idempotent re-run after retired roots are already gone).
1243
+ if ($subdirs.Count -gt 0) {
1244
+ $subdirPaths = [string[]]($subdirs | ForEach-Object { $_.FullName })
1245
+ [System.Array]::Sort($subdirPaths, [System.StringComparer]::Ordinal)
1246
+ [System.Array]::Reverse($subdirPaths)
1247
+ foreach ($dp in $subdirPaths) {
1248
+ if (Test-Path $dp -PathType Container) {
1249
+ $rem = @(Get-ChildItem -LiteralPath $dp -ErrorAction SilentlyContinue) | Select-Object -First 1
1250
+ if (-not $rem) {
1251
+ Remove-Item -LiteralPath $dp -Force
1252
+ if ($AidVerbose) { Write-Host "Pruned dir: $dp" }
1253
+ }
1223
1254
  }
1224
1255
  }
1225
1256
  }
@@ -1231,6 +1262,8 @@ function Invoke-PruneToolDirs {
1231
1262
  # that map points copilot-cli at the .github ROOT (forbidden by R1).
1232
1263
  # Scope is the R1-compliant set: .github/{agents,skills,aid} only.
1233
1264
  # -----------------------------------------------------------------------
1265
+ # Per-tool scoping: new layout (work-005/delivery-001).
1266
+ # Codex unified under .codex/; cursor and antigravity no longer ship rules/.
1234
1267
  switch ($Tool) {
1235
1268
  'claude-code' {
1236
1269
  & $pruneNativeDir (Join-Path $Target '.claude\agents')
@@ -1238,15 +1271,15 @@ function Invoke-PruneToolDirs {
1238
1271
  & $pruneAidSubtree (Join-Path $Target '.claude\aid')
1239
1272
  }
1240
1273
  'codex' {
1241
- # .codex ships only agents/; .agents ships skills/ + aid/ subtree.
1274
+ # New unified layout: everything under .codex/ (agents, skills, aid).
1242
1275
  & $pruneNativeDir (Join-Path $Target '.codex\agents')
1243
- & $pruneNativeDir (Join-Path $Target '.agents\skills')
1244
- & $pruneAidSubtree (Join-Path $Target '.agents\aid')
1276
+ & $pruneNativeDir (Join-Path $Target '.codex\skills')
1277
+ & $pruneAidSubtree (Join-Path $Target '.codex\aid')
1245
1278
  }
1246
1279
  'cursor' {
1280
+ # rules/ dir removed from new layout; agents/skills/aid remain.
1247
1281
  & $pruneNativeDir (Join-Path $Target '.cursor\agents')
1248
1282
  & $pruneNativeDir (Join-Path $Target '.cursor\skills')
1249
- & $pruneNativeDir (Join-Path $Target '.cursor\rules')
1250
1283
  & $pruneAidSubtree (Join-Path $Target '.cursor\aid')
1251
1284
  }
1252
1285
  'copilot-cli' {
@@ -1256,7 +1289,8 @@ function Invoke-PruneToolDirs {
1256
1289
  & $pruneAidSubtree (Join-Path $Target '.github\aid')
1257
1290
  }
1258
1291
  'antigravity' {
1259
- & $pruneNativeDir (Join-Path $Target '.agent\rules')
1292
+ # rules/ dir removed from new layout; agents/skills/aid remain.
1293
+ & $pruneNativeDir (Join-Path $Target '.agent\agents')
1260
1294
  & $pruneNativeDir (Join-Path $Target '.agent\skills')
1261
1295
  & $pruneAidSubtree (Join-Path $Target '.agent\aid')
1262
1296
  }
@@ -1267,6 +1301,140 @@ function Invoke-PruneToolDirs {
1267
1301
  }
1268
1302
  }
1269
1303
 
1304
+ # ---------------------------------------------------------------------------
1305
+ # Retired-root migration sweep (FR7/FR7a)
1306
+ # ---------------------------------------------------------------------------
1307
+
1308
+ # Invoke-MigrateRetiredLayout <target> <tool> [aidVerbose]
1309
+ #
1310
+ # Complete-replacement migration: removes AID-owned content from the static
1311
+ # list of retired AID roots that no longer exist in the new bundle layout.
1312
+ # Called from Install-AidTool BEFORE Invoke-PruneToolDirs (same aid update pass).
1313
+ #
1314
+ # Retired roots swept per tool:
1315
+ # codex: .agents\ (split layout retired)
1316
+ # cursor: .cursor\rules\ (rules dir retired)
1317
+ # antigravity: .agent\rules\ (rules dir retired)
1318
+ #
1319
+ # Ownership markers applied (content-isolation.md rules 1+2):
1320
+ # Marker 1: filename starts with "aid-" (tool-native dir files)
1321
+ # Marker 2: lives inside an "aid\" subtree
1322
+ #
1323
+ # Marker 3 (AID:BEGIN/END region in root files) is NOT touched here;
1324
+ # that is handled by Copy-RootAgentFile exclusively.
1325
+ #
1326
+ # User content (no marker) is NEVER removed.
1327
+ # Idempotent: a no-op when the retired path is already absent.
1328
+ # Sets $script:_MigrateRetiredCount with the count of items removed.
1329
+ function Invoke-MigrateRetiredLayout {
1330
+ param(
1331
+ [string]$Target,
1332
+ [string]$Tool,
1333
+ [bool]$AidVerbose = $false,
1334
+ [bool]$ListOnly = $false # $true = dry-run enumeration only (no removals)
1335
+ )
1336
+
1337
+ $script:_MigrateRetiredCount = 0
1338
+
1339
+ # Determine whether a file is AID-owned (markers 1 or 2).
1340
+ $isAidOwned = {
1341
+ param([System.IO.FileInfo]$File)
1342
+ # Marker 1: filename starts with "aid-".
1343
+ if ($File.Name -like 'aid-*') { return $true }
1344
+ # Marker 2: lives inside an "aid" folder (any ancestor named "aid").
1345
+ $dir = $File.Directory
1346
+ while ($dir -ne $null -and $dir.FullName -ne $Target -and $dir.FullName -ne $dir.Root.FullName) {
1347
+ if ($dir.Name -eq 'aid') { return $true }
1348
+ $dir = $dir.Parent
1349
+ }
1350
+ return $false
1351
+ }
1352
+
1353
+ # Sweep one retired root directory: move AID-owned files to trash, prune empty
1354
+ # dirs, then remove the retired root itself if now empty.
1355
+ # In ListOnly mode: enumerate would-be-moved files, make no changes.
1356
+ $sweepRetiredRoot = {
1357
+ param([string]$RDir)
1358
+ if (-not (Test-Path $RDir -PathType Container)) { return }
1359
+
1360
+ $trashBase = Join-Path $Target (Join-Path '.aid' '.trash')
1361
+
1362
+ # Walk all files; move (or collect) AID-owned ones.
1363
+ $files = @(Get-ChildItem -LiteralPath $RDir -Recurse -File -ErrorAction SilentlyContinue |
1364
+ Sort-Object FullName)
1365
+ foreach ($f in $files) {
1366
+ if (& $isAidOwned $f) {
1367
+ if ($ListOnly) {
1368
+ # Emit path via Write-Output so the caller can capture with $(...).
1369
+ Write-Output $f.FullName
1370
+ $script:_MigrateRetiredCount++
1371
+ } else {
1372
+ # Compute relative path from $Target and move to trash.
1373
+ $rel = $f.FullName.Substring($Target.Length).TrimStart([char]'\', [char]'/')
1374
+ $dest = Join-Path $trashBase $rel
1375
+ $destDir = Split-Path $dest -Parent
1376
+ New-Item -ItemType Directory -Path $destDir -Force -ErrorAction SilentlyContinue | Out-Null
1377
+ Move-Item -LiteralPath $f.FullName -Destination $dest -Force -ErrorAction SilentlyContinue
1378
+ $script:_MigrateRetiredCount++
1379
+ if ($AidVerbose) { Write-Host "Trashed: $($f.FullName) -> $dest" }
1380
+ }
1381
+ }
1382
+ }
1383
+
1384
+ if ($ListOnly) { return }
1385
+
1386
+ # Prune now-empty subdirs (deepest first).
1387
+ $subdirs = @(Get-ChildItem -LiteralPath $RDir -Recurse -Directory -ErrorAction SilentlyContinue)
1388
+ # Guard: pipeline over an empty @() returns $null on PowerShell; [System.Array]::Sort($null)
1389
+ # throws "Value cannot be null" when the retired root has no subdirectories (e.g. cursor
1390
+ # .cursor/rules/ contains only files, no nested dirs; or on idempotent re-run when the dir
1391
+ # is already absent). Mirrors bash rm -rf / rmdir which silently no-op on absent paths.
1392
+ if ($subdirs.Count -gt 0) {
1393
+ $subPaths = [string[]]($subdirs | ForEach-Object { $_.FullName })
1394
+ [System.Array]::Sort($subPaths, [System.StringComparer]::Ordinal)
1395
+ [System.Array]::Reverse($subPaths)
1396
+ foreach ($dp in $subPaths) {
1397
+ if (Test-Path $dp -PathType Container) {
1398
+ $rem = @(Get-ChildItem -LiteralPath $dp -ErrorAction SilentlyContinue) | Select-Object -First 1
1399
+ if (-not $rem) {
1400
+ Remove-Item -LiteralPath $dp -Force -ErrorAction SilentlyContinue
1401
+ if ($AidVerbose) { Write-Host "Retired dir: $dp" }
1402
+ }
1403
+ }
1404
+ }
1405
+ }
1406
+
1407
+ # Remove the retired root itself if now empty.
1408
+ if (Test-Path $RDir -PathType Container) {
1409
+ $rem = @(Get-ChildItem -LiteralPath $RDir -ErrorAction SilentlyContinue) | Select-Object -First 1
1410
+ if (-not $rem) {
1411
+ Remove-Item -LiteralPath $RDir -Force -ErrorAction SilentlyContinue
1412
+ if ($AidVerbose) { Write-Host "Retired root dir: $RDir" }
1413
+ }
1414
+ }
1415
+ }
1416
+
1417
+ switch ($Tool) {
1418
+ 'codex' {
1419
+ # Retired root: .agents\ (old split layout -- skills\ + aid\ lived here).
1420
+ & $sweepRetiredRoot (Join-Path $Target '.agents')
1421
+ }
1422
+ 'cursor' {
1423
+ # Retired root: .cursor\rules\ (rules dir no longer in new layout).
1424
+ & $sweepRetiredRoot (Join-Path $Target '.cursor\rules')
1425
+ }
1426
+ 'antigravity' {
1427
+ # Retired root: .agent\rules\ (rules dir no longer in new layout).
1428
+ & $sweepRetiredRoot (Join-Path $Target '.agent\rules')
1429
+ }
1430
+ # claude-code and copilot-cli have no retired roots in this migration.
1431
+ }
1432
+
1433
+ if (-not $ListOnly -and $script:_MigrateRetiredCount -gt 0) {
1434
+ Write-Host " $($script:_MigrateRetiredCount) retired AID file(s) moved to .aid/.trash/"
1435
+ }
1436
+ }
1437
+
1270
1438
  # ---------------------------------------------------------------------------
1271
1439
  # Version marker
1272
1440
  # ---------------------------------------------------------------------------
@@ -1339,16 +1507,13 @@ function Install-AidTool {
1339
1507
  }
1340
1508
  }
1341
1509
  'codex' {
1510
+ # New unified layout: .codex\ only (agents, skills, aid all under .codex\).
1511
+ # .agents\ is the RETIRED split layout -- handled by Invoke-MigrateRetiredLayout.
1342
1512
  $codexDir = Join-Path $StagingDir '.codex'
1343
- $agentsDir = Join-Path $StagingDir '.agents'
1344
1513
  if (Test-Path $codexDir -PathType Container) {
1345
1514
  Copy-AidDir -SrcDir $codexDir -DstDir (Join-Path $Target '.codex') -Force $Force -AidVerbose $AidVerbose
1346
1515
  & $collectPaths $codexDir $StagingDir $installPaths
1347
1516
  }
1348
- if (Test-Path $agentsDir -PathType Container) {
1349
- Copy-AidDir -SrcDir $agentsDir -DstDir (Join-Path $Target '.agents') -Force $Force -AidVerbose $AidVerbose
1350
- & $collectPaths $agentsDir $StagingDir $installPaths
1351
- }
1352
1517
  }
1353
1518
  'cursor' {
1354
1519
  $cursorDir = Join-Path $StagingDir '.cursor'
@@ -1389,10 +1554,33 @@ function Install-AidTool {
1389
1554
  $installPaths.Add($rootAgentFile)
1390
1555
  }
1391
1556
 
1557
+ # Manifest-seam entry gate (PLAN risk #3, delivery-001->002 seam).
1558
+ # Runs BEFORE Write-AidManifest so a contaminated bundle never writes to disk.
1559
+ # Assert that the new bundle's path set does NOT contain any retired roots.
1560
+ # If a retired path leaked into the new manifest, fail loudly -- do not prune
1561
+ # against a contaminated manifest (content-isolation cornerstone).
1562
+ $retiredRoots = @('.agents/', '.cursor/rules/', '.agent/rules/')
1563
+ foreach ($rr in $retiredRoots) {
1564
+ foreach ($p in $installPaths) {
1565
+ $pNorm = $p -replace '\\', '/'
1566
+ if ($pNorm.StartsWith($rr, [System.StringComparison]::Ordinal)) {
1567
+ [Console]::Error.WriteLine("ERROR: aid-install-core: manifest-seam violation: retired root '$rr' leaked into the new bundle manifest (path: $p). Aborting install to protect user content.")
1568
+ return 1
1569
+ }
1570
+ }
1571
+ }
1572
+
1392
1573
  # Write manifest (merge).
1393
1574
  Write-AidManifest -ManifestPath $manifest -Tool $Tool -Version $Version `
1394
1575
  -Paths @($installPaths) -RootEntries @($rootEntries)
1395
1576
 
1577
+ # Retired-root migration sweep (FR7/FR7a).
1578
+ # Remove AID-owned content from retired layout dirs BEFORE the normal prune,
1579
+ # so that old .agents\, .cursor\rules\, .agent\rules\ trees are cleaned up.
1580
+ # A non-zero rc from Install-AidTool is treated as a mid-commit failure (caller
1581
+ # prints the "re-run to heal" message); this function returns 0 (WARN-not-fail).
1582
+ Invoke-MigrateRetiredLayout -Target $Target -Tool $Tool -AidVerbose $AidVerbose
1583
+
1396
1584
  # Prune stale AID-owned files (Pillar 2, R7).
1397
1585
  # Build a HashSet from the new manifest path set for O(1) lookup.
1398
1586
  $pruneSet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal)
@@ -1547,7 +1735,12 @@ function Get-ManifestToolList {
1547
1735
  $data.tools.PSObject.Properties | ForEach-Object {
1548
1736
  $tid = $_.Name
1549
1737
  $t = $_.Value
1550
- $ver = if ($t.version) { $t.version } else { '' }
1738
+ # Guard all per-tool property accesses via PSObject.Properties['key'] first.
1739
+ # Set-StrictMode -Version Latest (active in this module) throws
1740
+ # PropertyNotFoundException when accessing a missing property directly
1741
+ # (e.g. $t.root_agent_files on pre-work-005 manifests that lack it).
1742
+ # PSObject.Properties['key'] returns $null safely for absent properties.
1743
+ $ver = if ($t.PSObject.Properties['version']) { [string]$t.version } else { '' }
1551
1744
  # Determine root agent file for this tool.
1552
1745
  $rootAgent = switch ($tid) {
1553
1746
  'claude-code' { 'CLAUDE.md' }
@@ -1555,9 +1748,10 @@ function Get-ManifestToolList {
1555
1748
  }
1556
1749
  # Read root agent status from manifest.
1557
1750
  $rootStatus = ''
1558
- if ($t.root_agent_files) {
1559
- foreach ($entry in $t.root_agent_files) {
1560
- if ($entry.path -eq $rootAgent) {
1751
+ $rafEntries = if ($t.PSObject.Properties['root_agent_files']) { $t.root_agent_files } else { $null }
1752
+ if ($rafEntries) {
1753
+ foreach ($entry in $rafEntries) {
1754
+ if ($entry.PSObject.Properties['path'] -and ($entry.path -eq $rootAgent)) {
1561
1755
  $rootStatus = if ($entry.PSObject.Properties['status']) { $entry.status } else { 'owned' }
1562
1756
  break
1563
1757
  }
@@ -1761,7 +1955,7 @@ function Get-AidStatus {
1761
1955
  $aidVersion = ''
1762
1956
  try {
1763
1957
  $data = Get-Content -LiteralPath $manifest -Raw | ConvertFrom-Json
1764
- if ($data.aid_version) { $aidVersion = $data.aid_version }
1958
+ if ($data.PSObject.Properties['aid_version'] -and $data.aid_version) { $aidVersion = $data.aid_version }
1765
1959
  } catch {}
1766
1960
 
1767
1961
  # Read CLI ref version from $env:AID_HOME/VERSION.