microsoft/hve-core

Public

mirrored fromhttps://github.com/microsoft/hve-coreAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
hve-core-v3.2.1

Branches

Tags

  • No tags available.
0Branches0Tags
Go to file
Add file
Code

Clone

HTTPS

Download ZIP

scripts/extension/Prepare-Extension.ps1

1875lines · modecode

1#!/usr/bin/env pwsh
2# Copyright (c) Microsoft Corporation.
3# SPDX-License-Identifier: MIT
4#Requires -Version 7.0
5
6<#
7.SYNOPSIS
8 Prepares the HVE Core VS Code extension for packaging.
9
10.DESCRIPTION
11 This script prepares the VS Code extension by:
12 - Auto-discovering chat agents, prompts, and instruction files
13 - Filtering agents by maturity level based on channel
14 - Updating package.json with discovered components
15 - Updating changelog if provided
16
17 The package.json version is not modified.
18
19.PARAMETER ChangelogPath
20 Optional. Path to a changelog file to include in the package.
21
22.PARAMETER Channel
23 Optional. Release channel controlling which maturity levels are included.
24 'Stable' (default): Only includes agents with maturity 'stable'.
25 'PreRelease': Includes 'stable', 'preview', and 'experimental' maturity levels.
26
27.PARAMETER DryRun
28 Optional. If specified, shows what would be done without making changes.
29
30.EXAMPLE
31 ./Prepare-Extension.ps1
32 # Prepares stable channel using existing version from package.json
33
34.EXAMPLE
35 ./Prepare-Extension.ps1 -Channel PreRelease
36 # Prepares pre-release channel including experimental agents
37
38.EXAMPLE
39 ./Prepare-Extension.ps1 -ChangelogPath "./CHANGELOG.md"
40 # Prepares with changelog
41
42.NOTES
43 Dependencies: PowerShell-Yaml module
44#>
45
46[CmdletBinding()]
47param(
48 [Parameter(Mandatory = $false)]
49 [string]$ChangelogPath = "",
50
51 [Parameter(Mandatory = $false)]
52 [ValidateSet('Stable', 'PreRelease')]
53 [string]$Channel = 'Stable',
54
55 [Parameter(Mandatory = $false)]
56 [switch]$DryRun,
57
58 [Parameter(Mandatory = $false)]
59 [string]$Collection = ""
60)
61
62$ErrorActionPreference = 'Stop'
63
64Import-Module (Join-Path $PSScriptRoot "../lib/Modules/CIHelpers.psm1") -Force
65Import-Module (Join-Path $PSScriptRoot "../collections/Modules/CollectionHelpers.psm1") -Force
66
67#region Pure Functions
68
69#region Package Generation Functions
70
71function Get-CollectionDisplayName {
72 <#
73 .SYNOPSIS
74 Resolves a display name from a collection manifest.
75 .DESCRIPTION
76 Returns the displayName field if set, derives one from the name field,
77 or falls back to a default value.
78 .PARAMETER CollectionManifest
79 Parsed collection manifest hashtable.
80 .PARAMETER DefaultValue
81 Fallback display name when the manifest provides neither displayName nor name.
82 .OUTPUTS
83 [string] Resolved display name.
84 #>
85 [CmdletBinding()]
86 [OutputType([string])]
87 param(
88 [Parameter(Mandatory = $true)]
89 [hashtable]$CollectionManifest,
90
91 [Parameter(Mandatory = $true)]
92 [string]$DefaultValue
93 )
94
95 if ($CollectionManifest.ContainsKey('displayName') -and -not [string]::IsNullOrWhiteSpace([string]$CollectionManifest.displayName)) {
96 return [string]$CollectionManifest.displayName
97 }
98
99 if ($CollectionManifest.ContainsKey('name') -and -not [string]::IsNullOrWhiteSpace([string]$CollectionManifest.name)) {
100 return "HVE Core - $($CollectionManifest.name)"
101 }
102
103 return $DefaultValue
104}
105
106function Copy-TemplateWithOverrides {
107 <#
108 .SYNOPSIS
109 Clones a template object and applies field overrides.
110 .DESCRIPTION
111 Copies all properties from Template, replacing any whose key appears in
112 Overrides. Additional override keys not in the template are appended.
113 .PARAMETER Template
114 Source PSCustomObject to clone.
115 .PARAMETER Overrides
116 Hashtable of field values to override or add.
117 .OUTPUTS
118 [pscustomobject] New object with overrides applied.
119 #>
120 [CmdletBinding()]
121 [OutputType([pscustomobject])]
122 param(
123 [Parameter(Mandatory = $true)]
124 [pscustomobject]$Template,
125
126 [Parameter(Mandatory = $true)]
127 [hashtable]$Overrides
128 )
129
130 $output = [ordered]@{}
131
132 foreach ($propertyName in $Template.PSObject.Properties.Name) {
133 if ($Overrides.ContainsKey($propertyName)) {
134 $output[$propertyName] = $Overrides[$propertyName]
135 }
136 else {
137 $output[$propertyName] = $Template.$propertyName
138 }
139 }
140
141 foreach ($propertyName in $Overrides.Keys | Sort-Object) {
142 if (-not $output.Contains($propertyName)) {
143 $output[$propertyName] = $Overrides[$propertyName]
144 }
145 }
146
147 return [pscustomobject]$output
148}
149
150function Set-JsonFile {
151 <#
152 .SYNOPSIS
153 Writes an object to a JSON file with UTF-8 encoding.
154 .DESCRIPTION
155 Serializes Content to JSON and writes to Path, creating parent
156 directories as needed.
157 .PARAMETER Path
158 Destination file path.
159 .PARAMETER Content
160 Object to serialize.
161 #>
162 [CmdletBinding()]
163 param(
164 [Parameter(Mandatory = $true)]
165 [string]$Path,
166
167 [Parameter(Mandatory = $true)]
168 [object]$Content
169 )
170
171 $parent = Split-Path -Path $Path -Parent
172 if (-not (Test-Path -Path $parent)) {
173 New-Item -Path $parent -ItemType Directory -Force | Out-Null
174 }
175
176 $json = $Content | ConvertTo-Json -Depth 30
177 Set-Content -Path $Path -Value $json -Encoding utf8NoBOM
178}
179
180function Remove-StaleGeneratedFiles {
181 <#
182 .SYNOPSIS
183 Removes generated collection package files that are no longer expected.
184 .DESCRIPTION
185 Scans extension/ for package.*.json files and removes any not in the
186 expected set, keeping the directory clean of orphaned collection templates.
187 .PARAMETER RepoRoot
188 Repository root path.
189 .PARAMETER ExpectedFiles
190 Array of absolute paths that should be retained.
191 #>
192 [CmdletBinding()]
193 param(
194 [Parameter(Mandatory = $true)]
195 [string]$RepoRoot,
196
197 [Parameter(Mandatory = $true)]
198 [AllowEmptyCollection()]
199 [string[]]$ExpectedFiles
200 )
201
202 $expected = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
203 foreach ($file in $ExpectedFiles) {
204 $null = $expected.Add([System.IO.Path]::GetFullPath($file))
205 }
206
207 $extensionDir = Join-Path $RepoRoot 'extension'
208 Get-ChildItem -Path $extensionDir -Filter 'package.*.json' -File | ForEach-Object {
209 $fullPath = [System.IO.Path]::GetFullPath($_.FullName)
210 if (-not $expected.Contains($fullPath)) {
211 Remove-Item -Path $_.FullName -Force
212 }
213 }
214}
215
216function Invoke-ExtensionCollectionsGeneration {
217 <#
218 .SYNOPSIS
219 Generates collection package files from root collection manifests.
220 .DESCRIPTION
221 Reads the package template and each collections/*.collection.yml file,
222 producing extension/package.json (for hve-core) and
223 extension/package.{id}.json for every other collection. Stale collection
224 files are removed.
225 .PARAMETER RepoRoot
226 Repository root path containing collections/ and extension/templates/.
227 .OUTPUTS
228 [string[]] Array of generated file paths.
229 #>
230 [CmdletBinding()]
231 [OutputType([string[]])]
232 param(
233 [Parameter(Mandatory = $true)]
234 [string]$RepoRoot
235 )
236
237 $collectionsDir = Join-Path $RepoRoot 'collections'
238 $templatesDir = Join-Path $RepoRoot 'extension/templates'
239
240 $packageTemplatePath = Join-Path $templatesDir 'package.template.json'
241
242 if (-not (Test-Path $packageTemplatePath)) {
243 throw "Package template not found: $packageTemplatePath"
244 }
245
246 if (-not (Get-Module -ListAvailable -Name PowerShell-Yaml)) {
247 throw "Required module 'PowerShell-Yaml' is not installed."
248 }
249
250 Import-Module PowerShell-Yaml -ErrorAction Stop
251
252 $packageTemplate = Get-Content -Path $packageTemplatePath -Raw | ConvertFrom-Json
253
254 $collectionFiles = Get-ChildItem -Path $collectionsDir -Filter '*.collection.yml' -File | Sort-Object Name
255 if ($collectionFiles.Count -eq 0) {
256 throw "No root collection files found in $collectionsDir"
257 }
258
259 $expectedFiles = @()
260
261 foreach ($collectionFile in $collectionFiles) {
262 $collection = Get-CollectionManifest -CollectionPath $collectionFile.FullName
263 if ($collection -isnot [hashtable]) {
264 throw "Collection manifest must be a hashtable: $($collectionFile.FullName)"
265 }
266
267 $collectionId = [string]$collection.id
268 if ([string]::IsNullOrWhiteSpace($collectionId)) {
269 throw "Collection id is required: $($collectionFile.FullName)"
270 }
271
272 $collectionDescription = if ($collection.ContainsKey('description')) { [string]$collection.description } else { [string]$packageTemplate.description }
273
274 $extensionName = switch ($collectionId) {
275 'hve-core' { [string]$packageTemplate.name }
276 'hve-core-all' { 'hve-core-all' }
277 default { "hve-$collectionId" }
278 }
279 $extensionDisplayName = switch ($collectionId) {
280 'hve-core' { [string]$packageTemplate.displayName }
281 'hve-core-all' { 'HVE Core - All' }
282 default { Get-CollectionDisplayName -CollectionManifest $collection -DefaultValue ([string]$packageTemplate.displayName) }
283 }
284
285 $packageTemplateOutput = Copy-TemplateWithOverrides -Template $packageTemplate -Overrides @{
286 name = $extensionName
287 displayName = $extensionDisplayName
288 description = $collectionDescription
289 }
290
291 $packagePath = switch ($collectionId) {
292 'hve-core' { Join-Path $RepoRoot 'extension/package.json' }
293 'hve-core-all' { Join-Path $RepoRoot 'extension/package.hve-core-all.json' }
294 default { Join-Path $RepoRoot "extension/package.$collectionId.json" }
295 }
296
297 Set-JsonFile -Path $packagePath -Content $packageTemplateOutput
298 $expectedFiles += $packagePath
299 }
300
301 Remove-StaleGeneratedFiles -RepoRoot $RepoRoot -ExpectedFiles $expectedFiles
302
303 # Generate README files for each collection
304 $readmeTemplatePath = Join-Path $templatesDir 'README.template.md'
305 foreach ($collectionFile in $collectionFiles) {
306 $collection = Get-CollectionManifest -CollectionPath $collectionFile.FullName
307 $collectionId = [string]$collection.id
308
309 $collectionMdPath = Join-Path $collectionsDir "$collectionId.collection.md"
310 if (-not (Test-Path $collectionMdPath)) {
311 continue
312 }
313
314 $readmePath = switch ($collectionId) {
315 'hve-core' { Join-Path $RepoRoot 'extension/README.md' }
316 'hve-core-all' { Join-Path $RepoRoot 'extension/README.hve-core-all.md' }
317 default { Join-Path $RepoRoot "extension/README.$collectionId.md" }
318 }
319
320 New-CollectionReadme -Collection $collection -CollectionMdPath $collectionMdPath -TemplatePath $readmeTemplatePath -RepoRoot $RepoRoot -OutputPath $readmePath
321 }
322
323 return $expectedFiles
324}
325
326function Get-ArtifactDescription {
327 <#
328 .SYNOPSIS
329 Reads the description from an artifact file's YAML frontmatter.
330 .DESCRIPTION
331 Parses the YAML frontmatter block at the top of a markdown file and
332 returns the description field value. Returns an empty string when the
333 file is missing, has no frontmatter, or lacks a description field.
334 Strips the common " - Brought to you by microsoft/hve-core" suffix.
335 .PARAMETER FilePath
336 Absolute path to the artifact markdown file.
337 .OUTPUTS
338 [string] Description text, or empty string if unavailable.
339 #>
340 [CmdletBinding()]
341 [OutputType([string])]
342 param(
343 [Parameter(Mandatory = $true)]
344 [string]$FilePath
345 )
346
347 if (-not (Test-Path $FilePath)) {
348 return ''
349 }
350
351 $content = Get-Content -Path $FilePath -Raw
352 if ($content -match '(?s)^---\s*\r?\n(.*?)\r?\n---') {
353 $yamlBlock = $Matches[1]
354 try {
355 $frontmatter = ConvertFrom-Yaml -Yaml $yamlBlock
356 if ($frontmatter -is [hashtable] -and $frontmatter.ContainsKey('description')) {
357 $desc = [string]$frontmatter.description
358 # Strip the common branding suffix
359 $desc = $desc -replace '\s*-\s*Brought to you by microsoft/hve-core$', ''
360 return $desc.Trim()
361 }
362 }
363 catch {
364 Write-Verbose "Failed to parse frontmatter from $FilePath`: $_"
365 }
366 }
367
368 return ''
369}
370
371function New-CollectionReadme {
372 <#
373 .SYNOPSIS
374 Generates a README.md for an extension collection from a template.
375 .DESCRIPTION
376 Reads a README template and replaces placeholder tokens with collection
377 metadata, hand-authored body content, and auto-generated artifact tables
378 with descriptions read from each artifact's YAML frontmatter.
379 Tokens: {{DISPLAY_NAME}}, {{DESCRIPTION}}, {{BODY}}, {{ARTIFACTS}},
380 {{FULL_EDITION}}.
381 .PARAMETER Collection
382 Parsed collection manifest hashtable.
383 .PARAMETER CollectionMdPath
384 Path to the collection markdown body file.
385 .PARAMETER TemplatePath
386 Path to the README template file containing placeholder tokens.
387 .PARAMETER RepoRoot
388 Repository root path for resolving artifact file paths.
389 .PARAMETER OutputPath
390 Destination path for the generated README.
391 #>
392 [CmdletBinding()]
393 param(
394 [Parameter(Mandatory = $true)]
395 [hashtable]$Collection,
396
397 [Parameter(Mandatory = $true)]
398 [string]$CollectionMdPath,
399
400 [Parameter(Mandatory = $true)]
401 [string]$TemplatePath,
402
403 [Parameter(Mandatory = $true)]
404 [string]$RepoRoot,
405
406 [Parameter(Mandatory = $true)]
407 [string]$OutputPath
408 )
409
410 $collectionId = [string]$Collection.id
411 $displayName = switch ($collectionId) {
412 'hve-core' { 'HVE Core' }
413 'hve-core-all' { 'HVE Core - All' }
414 default { Get-CollectionDisplayName -CollectionManifest $Collection -DefaultValue "HVE Core - $collectionId" }
415 }
416 $description = if ($Collection.ContainsKey('description')) { [string]$Collection.description } else { '' }
417
418 $collectionMaturity = if ($Collection.ContainsKey('maturity') -and -not [string]::IsNullOrWhiteSpace([string]$Collection.maturity)) {
419 [string]$Collection.maturity
420 } else { 'stable' }
421
422 $maturityNotice = if ($collectionMaturity -eq 'experimental') {
423 '> **⚠️ Experimental** — This collection is experimental and available only in the Pre-Release channel. Contents may change or be removed without notice.'
424 } else { '' }
425
426 $bodyContent = (Get-Content -Path $CollectionMdPath -Raw).Trim()
427
428 # Collect artifacts with descriptions grouped by kind
429 $agents = @()
430 $prompts = @()
431 $instructions = @()
432 $skills = @()
433
434 if ($Collection.ContainsKey('items')) {
435 foreach ($item in $Collection.items) {
436 if (-not $item.ContainsKey('kind') -or -not $item.ContainsKey('path')) {
437 continue
438 }
439 $kind = [string]$item.kind
440 $path = [string]$item.path
441 $artifactName = Get-CollectionArtifactKey -Kind $kind -Path $path
442
443 # Resolve full file path for frontmatter reading
444 $resolvedPath = Join-Path $RepoRoot ($path -replace '^\./', '')
445 if ($kind -eq 'skill') {
446 $resolvedPath = Join-Path $resolvedPath 'SKILL.md'
447 }
448 $artifactDesc = Get-ArtifactDescription -FilePath $resolvedPath
449
450 $entry = @{ Name = $artifactName; Description = $artifactDesc }
451 switch ($kind) {
452 'agent' { $agents += $entry }
453 'prompt' { $prompts += $entry }
454 'instruction' { $instructions += $entry }
455 'skill' { $skills += $entry }
456 }
457 }
458 }
459
460 # Build markdown tables for each artifact kind
461 $artifactSections = [System.Text.StringBuilder]::new()
462
463 foreach ($section in @(
464 @{ Title = 'Chat Agents'; Items = $agents },
465 @{ Title = 'Prompts'; Items = $prompts },
466 @{ Title = 'Instructions'; Items = $instructions },
467 @{ Title = 'Skills'; Items = $skills }
468 )) {
469 if ($section.Items.Count -eq 0) { continue }
470
471 $null = $artifactSections.AppendLine("### $($section.Title)")
472 $null = $artifactSections.AppendLine()
473 $null = $artifactSections.AppendLine('| Name | Description |')
474 $null = $artifactSections.AppendLine('|------|-------------|')
475 foreach ($entry in ($section.Items | Sort-Object { $_.Name })) {
476 $null = $artifactSections.AppendLine("| **$($entry.Name)** | $($entry.Description) |")
477 }
478 $null = $artifactSections.AppendLine()
479 }
480
481 $fullEdition = if ($collectionId -notin @('hve-core', 'hve-core-all')) {
482 "## Full Edition`n`nLooking for more agents covering additional domains? Check out the full [HVE Core](https://marketplace.visualstudio.com/items?itemName=ise-hve-essentials.hve-core) extension."
483 }
484 else {
485 ''
486 }
487
488 # Read template and replace tokens
489 $template = Get-Content -Path $TemplatePath -Raw
490 $readmeContent = $template `
491 -replace '\{\{DISPLAY_NAME\}\}', $displayName `
492 -replace '\{\{DESCRIPTION\}\}', $description `
493 -replace '\{\{MATURITY_NOTICE\}\}', $maturityNotice `
494 -replace '\{\{BODY\}\}', $bodyContent `
495 -replace '\{\{ARTIFACTS\}\}', $artifactSections.ToString().TrimEnd() `
496 -replace '\{\{FULL_EDITION\}\}', $fullEdition
497
498 # Clean up blank lines left by empty token replacements
499 $readmeContent = $readmeContent -replace '(\r?\n){3,}', "`n`n"
500 $readmeContent = $readmeContent.TrimEnd() + "`n"
501
502 Set-Content -Path $OutputPath -Value $readmeContent -Encoding utf8NoBOM -NoNewline
503}
504
505#endregion Package Generation Functions
506
507function Get-AllowedMaturities {
508 <#
509 .SYNOPSIS
510 Returns allowed maturity levels based on release channel.
511 .DESCRIPTION
512 Pure function that determines which maturity levels (stable, preview, experimental)
513 are included in the extension package based on the specified channel.
514 .PARAMETER Channel
515 Release channel. 'Stable' returns only stable; 'PreRelease' includes all levels.
516 .OUTPUTS
517 [string[]] Array of allowed maturity level strings.
518 #>
519 [CmdletBinding()]
520 [OutputType([string[]])]
521 param(
522 [Parameter(Mandatory = $true)]
523 [ValidateSet('Stable', 'PreRelease')]
524 [string]$Channel
525 )
526
527 if ($Channel -eq 'PreRelease') {
528 return @('stable', 'preview', 'experimental')
529 }
530 return @('stable')
531}
532
533function Test-CollectionMaturityEligible {
534 <#
535 .SYNOPSIS
536 Checks whether a collection is eligible for the specified release channel.
537 .DESCRIPTION
538 Pure function that evaluates collection-level maturity against channel rules.
539 Experimental collections are eligible only for PreRelease. Deprecated collections
540 are excluded from all channels.
541 .PARAMETER CollectionManifest
542 Parsed collection manifest hashtable.
543 .PARAMETER Channel
544 Release channel ('Stable' or 'PreRelease').
545 .OUTPUTS
546 [hashtable] With IsEligible bool and Reason string.
547 #>
548 [CmdletBinding()]
549 [OutputType([hashtable])]
550 param(
551 [Parameter(Mandatory = $true)]
552 [hashtable]$CollectionManifest,
553
554 [Parameter(Mandatory = $true)]
555 [ValidateSet('Stable', 'PreRelease')]
556 [string]$Channel
557 )
558
559 $maturity = 'stable'
560 if ($CollectionManifest.ContainsKey('maturity') -and $CollectionManifest['maturity']) {
561 $maturity = $CollectionManifest['maturity']
562 }
563
564 switch ($maturity) {
565 'deprecated' {
566 return @{
567 IsEligible = $false
568 Reason = "Collection '$($CollectionManifest.id)' is deprecated and excluded from all channels"
569 }
570 }
571 'experimental' {
572 if ($Channel -eq 'Stable') {
573 return @{
574 IsEligible = $false
575 Reason = "Collection '$($CollectionManifest.id)' is experimental and excluded from Stable channel"
576 }
577 }
578 return @{ IsEligible = $true; Reason = '' }
579 }
580 'preview' {
581 return @{ IsEligible = $true; Reason = '' }
582 }
583 'stable' {
584 return @{ IsEligible = $true; Reason = '' }
585 }
586 default {
587 return @{
588 IsEligible = $false
589 Reason = "Collection '$($CollectionManifest.id)' has invalid maturity value: $maturity"
590 }
591 }
592 }
593}
594
595function Test-GlobMatch {
596 <#
597 .SYNOPSIS
598 Tests whether a name matches any of the provided glob patterns.
599 .DESCRIPTION
600 Uses PowerShell's -like operator to test glob pattern matching with
601 * (any characters) and ? (single character) wildcards.
602 .PARAMETER Name
603 The artifact name to test against patterns.
604 .PARAMETER Patterns
605 Array of glob patterns to match against.
606 .OUTPUTS
607 [bool] True if name matches any pattern, false otherwise.
608 #>
609 [CmdletBinding()]
610 [OutputType([bool])]
611 param(
612 [Parameter(Mandatory = $true)]
613 [string]$Name,
614
615 [Parameter(Mandatory = $true)]
616 [string[]]$Patterns
617 )
618
619 foreach ($pattern in $Patterns) {
620 if ($Name -like $pattern) {
621 return $true
622 }
623 }
624 return $false
625}
626
627function Get-CollectionArtifacts {
628 <#
629 .SYNOPSIS
630 Filters collection artifacts by collection item metadata and channel maturity.
631 .DESCRIPTION
632 Applies collection-level filtering to manifest items, returning artifact
633 names that match allowed maturities. Item-level maturity is used when
634 present; otherwise artifacts default to stable.
635 .PARAMETER Collection
636 Collection manifest hashtable with items.
637 .PARAMETER AllowedMaturities
638 Array of maturity levels to include.
639 .OUTPUTS
640 [hashtable] With Agents, Prompts, Instructions, Skills arrays of matching artifact names.
641 #>
642 [CmdletBinding()]
643 [OutputType([hashtable])]
644 param(
645 [Parameter(Mandatory = $true)]
646 [hashtable]$Collection,
647
648 [Parameter(Mandatory = $true)]
649 [string[]]$AllowedMaturities
650 )
651
652 $result = @{
653 Agents = @()
654 Prompts = @()
655 Instructions = @()
656 Skills = @()
657 }
658
659 if (-not $Collection.ContainsKey('items') -or @($Collection.items).Count -eq 0) {
660 return $result
661 }
662
663 foreach ($item in $Collection.items) {
664 if (-not $item.ContainsKey('kind') -or -not $item.ContainsKey('path')) {
665 continue
666 }
667
668 $kind = [string]$item.kind
669 $path = [string]$item.path
670
671 $maturity = Resolve-CollectionItemMaturity -Maturity $item.maturity
672 if ($AllowedMaturities -notcontains $maturity) {
673 continue
674 }
675
676 $artifactKey = Get-CollectionArtifactKey -Kind $kind -Path $path
677 switch ($kind) {
678 'agent' { $result.Agents += $artifactKey }
679 'prompt' { $result.Prompts += $artifactKey }
680 'instruction' { $result.Instructions += $artifactKey }
681 'skill' { $result.Skills += $artifactKey }
682 }
683 }
684
685 return $result
686}
687
688function Resolve-HandoffDependencies {
689 <#
690 .SYNOPSIS
691 Resolves transitive agent handoff dependencies using BFS traversal.
692 .DESCRIPTION
693 Starting from seed agents, performs breadth-first traversal of agent handoff
694 declarations in YAML frontmatter to compute the transitive closure of
695 all agents reachable through handoff chains.
696
697 Handoff targets in frontmatter use display names (e.g., "Task Planner")
698 while agent files use kebab-case stems (e.g., task-planner.agent.md).
699 This function builds a name index to resolve both formats.
700 .PARAMETER SeedAgents
701 Initial agent names (file stems) to start BFS from.
702 .PARAMETER AgentsDir
703 Path to the agents directory containing .agent.md files.
704 .OUTPUTS
705 [string[]] Complete set of agent file stems including seed agents and all transitive handoff targets.
706 #>
707 [CmdletBinding()]
708 [OutputType([string[]])]
709 param(
710 [Parameter(Mandatory = $true)]
711 [string[]]$SeedAgents,
712
713 [Parameter(Mandatory = $true)]
714 [string]$AgentsDir
715 )
716
717 # Build index: map display names and file stems to agent file objects.
718 # Handoff targets use display names from frontmatter (e.g., "RPI Agent")
719 # while seed agents and collection keys use file stems (e.g., "rpi-agent").
720 $agentIndex = @{}
721 $allAgentFiles = Get-ChildItem -Path $AgentsDir -Filter "*.agent.md" -Recurse -File
722 foreach ($af in $allAgentFiles) {
723 $stem = $af.BaseName -replace '\.agent$', ''
724 $agentIndex[$stem] = $af
725
726 $fc = Get-Content -Path $af.FullName -Raw
727 if ($fc -match '(?s)^---\s*\r?\n(.*?)\r?\n---') {
728 $yml = $Matches[1] -replace '\r\n', "`n" -replace '\r', "`n"
729 try {
730 $meta = ConvertFrom-Yaml -Yaml $yml
731 if ($meta.ContainsKey('name') -and $meta.name -is [string] -and $meta.name -ne '') {
732 if (-not $agentIndex.ContainsKey($meta.name)) {
733 $agentIndex[$meta.name] = $af
734 }
735 }
736 }
737 catch {
738 Write-Verbose "Skipping display name index for $($af.Name): $_"
739 }
740 }
741 }
742
743 $visited = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
744 $queue = [System.Collections.Generic.Queue[string]]::new()
745
746 foreach ($agent in $SeedAgents) {
747 if ($visited.Add($agent)) {
748 $queue.Enqueue($agent)
749 }
750 }
751
752 while ($queue.Count -gt 0) {
753 $current = $queue.Dequeue()
754 $agentFile = $agentIndex[$current]
755
756 if (-not $agentFile) {
757 Write-Warning "Handoff target agent file not found: $current"
758 continue
759 }
760
761 # Normalize visited entry to file stem for consistent collection filtering
762 $fileStem = $agentFile.BaseName -replace '\.agent$', ''
763 if ($fileStem -ne $current) {
764 $visited.Add($fileStem) | Out-Null
765 }
766
767 # Parse handoffs from frontmatter
768 $content = Get-Content -Path $agentFile.FullName -Raw
769 if ($content -match '(?s)^---\s*\r?\n(.*?)\r?\n---') {
770 $yamlContent = $Matches[1] -replace '\r\n', "`n" -replace '\r', "`n"
771 try {
772 $data = ConvertFrom-Yaml -Yaml $yamlContent
773 if ($data.ContainsKey('handoffs') -and $data.handoffs -is [System.Collections.IEnumerable] -and $data.handoffs -isnot [string]) {
774 foreach ($handoff in $data.handoffs) {
775 # Handle both string format and object format (with 'agent' field).
776 # Handoff targets bypass maturity filtering by design.
777 # See docs/contributing/ai-artifacts-common.md
778 # "Handoff vs Requires Maturity Filtering" for rationale.
779 $targetAgent = $null
780 if ($handoff -is [string]) {
781 $targetAgent = $handoff
782 } elseif ($handoff -is [hashtable] -and $handoff.ContainsKey('agent')) {
783 $targetAgent = $handoff.agent
784 }
785 if ($targetAgent -and $visited.Add($targetAgent)) {
786 $queue.Enqueue($targetAgent)
787 }
788 }
789 }
790 }
791 catch {
792 Write-Warning "Failed to parse handoffs from $($agentFile.Name): $_"
793 }
794 }
795 }
796
797 return @($visited)
798}
799
800function Resolve-RequiresDependencies {
801 <#
802 .SYNOPSIS
803 Resolves transitive artifact dependencies from collection item requires blocks.
804 .DESCRIPTION
805 Walks requires blocks in collection items to compute the complete set of
806 dependent artifacts across all types (agents, prompts, instructions, skills).
807 .PARAMETER ArtifactNames
808 Hashtable with initial artifact name arrays keyed by type (agents, prompts, instructions, skills).
809 .PARAMETER AllowedMaturities
810 Array of maturity levels to include.
811 .PARAMETER CollectionRequires
812 Per-type map of artifact requires blocks keyed by artifact name.
813 .PARAMETER CollectionMaturities
814 Optional per-type maturity map keyed by artifact name.
815 .OUTPUTS
816 [hashtable] With Agents, Prompts, Instructions, Skills arrays containing resolved names.
817 #>
818 [CmdletBinding()]
819 [OutputType([hashtable])]
820 param(
821 [Parameter(Mandatory = $true)]
822 [hashtable]$ArtifactNames,
823
824 [Parameter(Mandatory = $true)]
825 [string[]]$AllowedMaturities,
826
827 [Parameter(Mandatory = $false)]
828 [hashtable]$CollectionRequires = @{},
829
830 [Parameter(Mandatory = $false)]
831 [hashtable]$CollectionMaturities = @{}
832 )
833
834 $resolved = @{
835 Agents = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
836 Prompts = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
837 Instructions = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
838 Skills = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
839 }
840
841 $typeMap = @{
842 agents = 'Agents'
843 prompts = 'Prompts'
844 instructions = 'Instructions'
845 skills = 'Skills'
846 }
847
848 # Seed with initial artifact names
849 foreach ($type in @('agents', 'prompts', 'instructions', 'skills')) {
850 $capitalType = $typeMap[$type]
851 if ($ArtifactNames.ContainsKey($type)) {
852 foreach ($name in $ArtifactNames[$type]) {
853 $null = $resolved[$capitalType].Add($name)
854 }
855 }
856 }
857
858 $changed = $true
859 while ($changed) {
860 $changed = $false
861
862 foreach ($sourceType in @('agents', 'prompts', 'instructions', 'skills')) {
863 if (-not $CollectionRequires.ContainsKey($sourceType)) {
864 continue
865 }
866
867 $sourceCapitalType = $typeMap[$sourceType]
868 foreach ($sourceName in @($resolved[$sourceCapitalType])) {
869 if (-not $CollectionRequires[$sourceType].ContainsKey($sourceName)) {
870 continue
871 }
872
873 $requires = $CollectionRequires[$sourceType][$sourceName]
874 if (-not $requires) {
875 continue
876 }
877
878 foreach ($targetType in @('agents', 'prompts', 'instructions', 'skills')) {
879 if (-not $requires.ContainsKey($targetType)) {
880 continue
881 }
882
883 $targetCapitalType = $typeMap[$targetType]
884 foreach ($dep in @($requires[$targetType])) {
885 $depMaturity = 'stable'
886 if ($CollectionMaturities.ContainsKey($targetType) -and $CollectionMaturities[$targetType].ContainsKey($dep)) {
887 $depMaturity = $CollectionMaturities[$targetType][$dep]
888 }
889
890 if ($AllowedMaturities -notcontains $depMaturity) {
891 continue
892 }
893
894 if ($resolved[$targetCapitalType].Add($dep)) {
895 $changed = $true
896 }
897 }
898 }
899 }
900 }
901 }
902
903 # Convert HashSets to arrays
904 return @{
905 Agents = @($resolved.Agents)
906 Prompts = @($resolved.Prompts)
907 Instructions = @($resolved.Instructions)
908 Skills = @($resolved.Skills)
909 }
910}
911
912function Test-PathsExist {
913 <#
914 .SYNOPSIS
915 Validates that required paths exist for extension preparation.
916 .DESCRIPTION
917 Validation function that checks whether extension directory, package.json,
918 and .github directory exist at the specified locations.
919 .PARAMETER ExtensionDir
920 Path to the extension directory.
921 .PARAMETER PackageJsonPath
922 Path to package.json file.
923 .PARAMETER GitHubDir
924 Path to .github directory.
925 .OUTPUTS
926 [hashtable] With IsValid bool, MissingPaths array, and ErrorMessages array.
927 #>
928 [CmdletBinding()]
929 [OutputType([hashtable])]
930 param(
931 [Parameter(Mandatory = $true)]
932 [string]$ExtensionDir,
933
934 [Parameter(Mandatory = $true)]
935 [string]$PackageJsonPath,
936
937 [Parameter(Mandatory = $true)]
938 [string]$GitHubDir
939 )
940
941 $missingPaths = @()
942 $errorMessages = @()
943
944 if (-not (Test-Path $ExtensionDir)) {
945 $missingPaths += $ExtensionDir
946 $errorMessages += "Extension directory not found: $ExtensionDir"
947 }
948 if (-not (Test-Path $PackageJsonPath)) {
949 $missingPaths += $PackageJsonPath
950 $errorMessages += "package.json not found: $PackageJsonPath"
951 }
952 if (-not (Test-Path $GitHubDir)) {
953 $missingPaths += $GitHubDir
954 $errorMessages += ".github directory not found: $GitHubDir"
955 }
956
957 return @{
958 IsValid = ($missingPaths.Count -eq 0)
959 MissingPaths = $missingPaths
960 ErrorMessages = $errorMessages
961 }
962}
963
964function Get-DiscoveredAgents {
965 <#
966 .SYNOPSIS
967 Discovers chat agent files from the agents directory.
968 .DESCRIPTION
969 Discovery function that scans the agents directory for .agent.md files,
970 filters by exclusion list, and returns structured agent objects.
971 .PARAMETER AgentsDir
972 Path to the agents directory.
973 .PARAMETER AllowedMaturities
974 Array of maturity levels to include.
975 .PARAMETER ExcludedAgents
976 Array of agent names to exclude from packaging.
977 .OUTPUTS
978 [hashtable] With Agents array, Skipped array, and DirectoryExists bool.
979 #>
980 [CmdletBinding()]
981 [OutputType([hashtable])]
982 param(
983 [Parameter(Mandatory = $true)]
984 [string]$AgentsDir,
985
986 [Parameter(Mandatory = $true)]
987 [string[]]$AllowedMaturities,
988
989 [Parameter(Mandatory = $false)]
990 [string[]]$ExcludedAgents = @()
991 )
992
993 $result = @{
994 Agents = @()
995 Skipped = @()
996 DirectoryExists = (Test-Path $AgentsDir)
997 }
998
999 if (-not $result.DirectoryExists) {
1000 return $result
1001 }
1002
1003 $agentFiles = Get-ChildItem -Path $AgentsDir -Filter "*.agent.md" -Recurse | Sort-Object Name
1004 $agentFiles = $agentFiles | Where-Object { -not (Test-DeprecatedPath -Path $_.FullName) }
1005
1006 foreach ($agentFile in $agentFiles) {
1007 $agentRelPath = [System.IO.Path]::GetRelativePath($AgentsDir, $agentFile.FullName) -replace '\\', '/'
1008
1009 if (Test-HveCoreRepoSpecificPath -RelativePath $agentRelPath) {
1010 $agentName = $agentFile.BaseName -replace '\.agent$', ''
1011 $result.Skipped += @{ Name = $agentName; Reason = 'repo-specific (root-level)' }
1012 continue
1013 }
1014
1015 $agentName = $agentFile.BaseName -replace '\.agent$', ''
1016
1017 if ($ExcludedAgents -contains $agentName) {
1018 $result.Skipped += @{ Name = $agentName; Reason = 'excluded' }
1019 continue
1020 }
1021
1022 $maturity = "stable"
1023
1024 if ($AllowedMaturities -notcontains $maturity) {
1025 $result.Skipped += @{ Name = $agentName; Reason = "maturity: $maturity" }
1026 continue
1027 }
1028 $result.Agents += [PSCustomObject]@{
1029 name = $agentName
1030 path = "./.github/agents/$agentRelPath"
1031 }
1032 }
1033
1034 return $result
1035}
1036
1037function Get-DiscoveredPrompts {
1038 <#
1039 .SYNOPSIS
1040 Discovers prompt files from the prompts directory.
1041 .DESCRIPTION
1042 Discovery function that scans the prompts directory for .prompt.md files,
1043 and returns structured prompt objects with relative paths.
1044 .PARAMETER PromptsDir
1045 Path to the prompts directory.
1046 .PARAMETER GitHubDir
1047 Path to the .github directory for relative path calculation.
1048 .PARAMETER AllowedMaturities
1049 Array of maturity levels to include.
1050 .OUTPUTS
1051 [hashtable] With Prompts array, Skipped array, and DirectoryExists bool.
1052 #>
1053 [CmdletBinding()]
1054 [OutputType([hashtable])]
1055 param(
1056 [Parameter(Mandatory = $true)]
1057 [string]$PromptsDir,
1058
1059 [Parameter(Mandatory = $true)]
1060 [string]$GitHubDir,
1061
1062 [Parameter(Mandatory = $true)]
1063 [string[]]$AllowedMaturities
1064 )
1065
1066 $result = @{
1067 Prompts = @()
1068 Skipped = @()
1069 DirectoryExists = (Test-Path $PromptsDir)
1070 }
1071
1072 if (-not $result.DirectoryExists) {
1073 return $result
1074 }
1075
1076 $promptFiles = Get-ChildItem -Path $PromptsDir -Filter "*.prompt.md" -Recurse | Sort-Object Name
1077 $promptFiles = $promptFiles | Where-Object { -not (Test-DeprecatedPath -Path $_.FullName) }
1078
1079 foreach ($promptFile in $promptFiles) {
1080 $promptName = $promptFile.BaseName -replace '\.prompt$', ''
1081
1082 $promptRelPath = [System.IO.Path]::GetRelativePath($PromptsDir, $promptFile.FullName) -replace '\\', '/'
1083 if (Test-HveCoreRepoSpecificPath -RelativePath $promptRelPath) {
1084 $result.Skipped += @{ Name = $promptName; Reason = 'repo-specific (root-level)' }
1085 continue
1086 }
1087
1088 $maturity = "stable"
1089
1090 if ($AllowedMaturities -notcontains $maturity) {
1091 $result.Skipped += @{ Name = $promptName; Reason = "maturity: $maturity" }
1092 continue
1093 }
1094
1095 $relativePath = [System.IO.Path]::GetRelativePath($GitHubDir, $promptFile.FullName) -replace '\\', '/'
1096
1097 $result.Prompts += [PSCustomObject]@{
1098 name = $promptName
1099 path = "./.github/$relativePath"
1100 }
1101 }
1102
1103 return $result
1104}
1105
1106function Get-DiscoveredInstructions {
1107 <#
1108 .SYNOPSIS
1109 Discovers instruction files from the instructions directory.
1110 .DESCRIPTION
1111 Discovery function that scans the instructions directory for .instructions.md files,
1112 and returns structured instruction objects with normalized paths.
1113 .PARAMETER InstructionsDir
1114 Path to the instructions directory.
1115 .PARAMETER GitHubDir
1116 Path to the .github directory for relative path calculation.
1117 .PARAMETER AllowedMaturities
1118 Array of maturity levels to include.
1119 .OUTPUTS
1120 [hashtable] With Instructions array, Skipped array, and DirectoryExists bool.
1121 #>
1122 [CmdletBinding()]
1123 [OutputType([hashtable])]
1124 param(
1125 [Parameter(Mandatory = $true)]
1126 [string]$InstructionsDir,
1127
1128 [Parameter(Mandatory = $true)]
1129 [string]$GitHubDir,
1130
1131 [Parameter(Mandatory = $true)]
1132 [string[]]$AllowedMaturities
1133 )
1134
1135 $result = @{
1136 Instructions = @()
1137 Skipped = @()
1138 DirectoryExists = (Test-Path $InstructionsDir)
1139 }
1140
1141 if (-not $result.DirectoryExists) {
1142 return $result
1143 }
1144
1145 $instructionFiles = Get-ChildItem -Path $InstructionsDir -Filter "*.instructions.md" -Recurse | Sort-Object Name
1146 $instructionFiles = $instructionFiles | Where-Object { -not (Test-DeprecatedPath -Path $_.FullName) }
1147
1148 foreach ($instrFile in $instructionFiles) {
1149 $instrRelPath = [System.IO.Path]::GetRelativePath($InstructionsDir, $instrFile.FullName) -replace '\\', '/'
1150 if (Test-HveCoreRepoSpecificPath -RelativePath $instrRelPath) {
1151 $result.Skipped += @{ Name = $instrFile.BaseName; Reason = 'repo-specific (root-level)' }
1152 continue
1153 }
1154 $baseName = $instrFile.BaseName -replace '\.instructions$', ''
1155 $instrName = "$baseName-instructions"
1156
1157 $maturity = "stable"
1158
1159 if ($AllowedMaturities -notcontains $maturity) {
1160 $result.Skipped += @{ Name = $instrName; Reason = "maturity: $maturity" }
1161 continue
1162 }
1163
1164 $relativePathFromGitHub = [System.IO.Path]::GetRelativePath($GitHubDir, $instrFile.FullName)
1165 $normalizedRelativePath = (Join-Path ".github" $relativePathFromGitHub) -replace '\\', '/'
1166
1167 $result.Instructions += [PSCustomObject]@{
1168 name = $instrName
1169 path = "./$normalizedRelativePath"
1170 }
1171 }
1172
1173 return $result
1174}
1175
1176function Get-DiscoveredSkills {
1177 <#
1178 .SYNOPSIS
1179 Discovers skill packages from the skills directory.
1180 .DESCRIPTION
1181 Discovery function that scans the skills directory for subdirectories
1182 containing SKILL.md files and returns structured skill objects.
1183 .PARAMETER SkillsDir
1184 Path to the skills directory.
1185 .PARAMETER AllowedMaturities
1186 Array of maturity levels to include.
1187 .OUTPUTS
1188 [hashtable] With Skills array, Skipped array, and DirectoryExists bool.
1189 #>
1190 [CmdletBinding()]
1191 [OutputType([hashtable])]
1192 param(
1193 [Parameter(Mandatory = $true)]
1194 [string]$SkillsDir,
1195
1196 [Parameter(Mandatory = $true)]
1197 [string[]]$AllowedMaturities
1198 )
1199
1200 $result = @{
1201 Skills = @()
1202 Skipped = @()
1203 DirectoryExists = (Test-Path $SkillsDir)
1204 }
1205
1206 if (-not $result.DirectoryExists) {
1207 return $result
1208 }
1209
1210 $skillFiles = Get-ChildItem -Path $SkillsDir -Filter "SKILL.md" -File -Recurse | Sort-Object { $_.Directory.FullName }
1211 $skillFiles = $skillFiles | Where-Object { -not (Test-DeprecatedPath -Path $_.FullName) }
1212
1213 foreach ($skillFile in $skillFiles) {
1214 $skillDir = $skillFile.Directory
1215 $skillName = $skillDir.Name
1216 $skillRelPath = [System.IO.Path]::GetRelativePath($SkillsDir, $skillDir.FullName) -replace '\\', '/'
1217
1218 if (Test-HveCoreRepoSpecificPath -RelativePath $skillRelPath) {
1219 $result.Skipped += @{ Name = $skillName; Reason = 'repo-specific (root-level)' }
1220 continue
1221 }
1222
1223 $maturity = "stable"
1224
1225 if ($AllowedMaturities -notcontains $maturity) {
1226 $result.Skipped += @{ Name = $skillName; Reason = "maturity: $maturity" }
1227 continue
1228 }
1229
1230 $result.Skills += [PSCustomObject]@{
1231 name = $skillName
1232 path = "./.github/skills/$skillRelPath/SKILL.md"
1233 }
1234 }
1235
1236 return $result
1237}
1238
1239function Update-PackageJsonContributes {
1240 <#
1241 .SYNOPSIS
1242 Updates package.json contributes section with discovered components.
1243 .DESCRIPTION
1244 Pure function that takes a package.json object and discovered components,
1245 returning a new object with the contributes section updated. Handles
1246 chatAgents, chatPromptFiles, chatInstructions, and chatSkills.
1247 .PARAMETER PackageJson
1248 The package.json object to update.
1249 .PARAMETER ChatAgents
1250 Array of discovered chat agent objects.
1251 .PARAMETER ChatPromptFiles
1252 Array of discovered prompt objects.
1253 .PARAMETER ChatInstructions
1254 Array of discovered instruction objects.
1255 .PARAMETER ChatSkills
1256 Array of discovered skill objects.
1257 .OUTPUTS
1258 [PSCustomObject] Updated package.json object.
1259 #>
1260 [CmdletBinding()]
1261 [OutputType([PSCustomObject])]
1262 param(
1263 [Parameter(Mandatory = $true)]
1264 [PSCustomObject]$PackageJson,
1265
1266 [Parameter(Mandatory = $true)]
1267 [AllowEmptyCollection()]
1268 [array]$ChatAgents,
1269
1270 [Parameter(Mandatory = $true)]
1271 [AllowEmptyCollection()]
1272 [array]$ChatPromptFiles,
1273
1274 [Parameter(Mandatory = $true)]
1275 [AllowEmptyCollection()]
1276 [array]$ChatInstructions,
1277
1278 [Parameter(Mandatory = $true)]
1279 [AllowEmptyCollection()]
1280 [array]$ChatSkills
1281 )
1282
1283 # Clone the object to avoid modifying the original
1284 $updated = $PackageJson | ConvertTo-Json -Depth 10 | ConvertFrom-Json
1285
1286 # Strip name and description; VS Code reads these from the files directly
1287 $ChatAgents = @($ChatAgents | Select-Object -Property path)
1288 $ChatPromptFiles = @($ChatPromptFiles | Select-Object -Property path)
1289 $ChatInstructions = @($ChatInstructions | Select-Object -Property path)
1290 $ChatSkills = @($ChatSkills | Select-Object -Property path)
1291
1292 # Ensure contributes section exists
1293 if (-not $updated.contributes) {
1294 $updated | Add-Member -NotePropertyName "contributes" -NotePropertyValue ([PSCustomObject]@{})
1295 }
1296
1297 # Add or update contributes properties
1298 if ($null -eq $updated.contributes.chatAgents) {
1299 $updated.contributes | Add-Member -NotePropertyName "chatAgents" -NotePropertyValue $ChatAgents -Force
1300 } else {
1301 $updated.contributes.chatAgents = $ChatAgents
1302 }
1303
1304 if ($null -eq $updated.contributes.chatPromptFiles) {
1305 $updated.contributes | Add-Member -NotePropertyName "chatPromptFiles" -NotePropertyValue $ChatPromptFiles -Force
1306 } else {
1307 $updated.contributes.chatPromptFiles = $ChatPromptFiles
1308 }
1309
1310 if ($null -eq $updated.contributes.chatInstructions) {
1311 $updated.contributes | Add-Member -NotePropertyName "chatInstructions" -NotePropertyValue $ChatInstructions -Force
1312 } else {
1313 $updated.contributes.chatInstructions = $ChatInstructions
1314 }
1315
1316 if ($null -eq $updated.contributes.chatSkills) {
1317 $updated.contributes | Add-Member -NotePropertyName "chatSkills" -NotePropertyValue $ChatSkills -Force
1318 } else {
1319 $updated.contributes.chatSkills = $ChatSkills
1320 }
1321
1322 return $updated
1323}
1324
1325function New-PrepareResult {
1326 <#
1327 .SYNOPSIS
1328 Creates a standardized result object for extension preparation operations.
1329 .DESCRIPTION
1330 Factory function that creates a hashtable with consistent properties
1331 for reporting preparation operation outcomes.
1332 .PARAMETER Success
1333 Indicates whether the operation completed successfully.
1334 .PARAMETER Version
1335 The version string from package.json.
1336 .PARAMETER AgentCount
1337 Number of agents discovered and included.
1338 .PARAMETER PromptCount
1339 Number of prompts discovered and included.
1340 .PARAMETER InstructionCount
1341 Number of instructions discovered and included.
1342 .PARAMETER SkillCount
1343 Number of skills discovered and included.
1344 .PARAMETER ErrorMessage
1345 Error description when Success is false.
1346 .OUTPUTS
1347 Hashtable with Success, Version, AgentCount, PromptCount,
1348 InstructionCount, SkillCount, and ErrorMessage properties.
1349 #>
1350 [CmdletBinding()]
1351 [OutputType([hashtable])]
1352 param(
1353 [Parameter(Mandatory = $true)]
1354 [bool]$Success,
1355
1356 [Parameter(Mandatory = $false)]
1357 [string]$Version = "",
1358
1359 [Parameter(Mandatory = $false)]
1360 [int]$AgentCount = 0,
1361
1362 [Parameter(Mandatory = $false)]
1363 [int]$PromptCount = 0,
1364
1365 [Parameter(Mandatory = $false)]
1366 [int]$InstructionCount = 0,
1367
1368 [Parameter(Mandatory = $false)]
1369 [int]$SkillCount = 0,
1370
1371 [Parameter(Mandatory = $false)]
1372 [string]$ErrorMessage = ""
1373 )
1374
1375 return @{
1376 Success = $Success
1377 Version = $Version
1378 AgentCount = $AgentCount
1379 PromptCount = $PromptCount
1380 InstructionCount = $InstructionCount
1381 SkillCount = $SkillCount
1382 ErrorMessage = $ErrorMessage
1383 }
1384}
1385
1386function Test-TemplateConsistency {
1387 <#
1388 .SYNOPSIS
1389 Validates collection template metadata against its collection manifest.
1390 .DESCRIPTION
1391 Compares name, displayName, and description fields between a collection
1392 package template (e.g. package.developer.json) and the corresponding
1393 collection manifest. Emits warnings for divergences and returns a list
1394 of mismatches.
1395 .PARAMETER TemplatePath
1396 Path to the collection package template JSON file.
1397 .PARAMETER CollectionManifest
1398 Parsed collection manifest hashtable with name, displayName, description.
1399 .OUTPUTS
1400 [hashtable] With Mismatches array and IsConsistent bool.
1401 #>
1402 [CmdletBinding()]
1403 [OutputType([hashtable])]
1404 param(
1405 [Parameter(Mandatory = $true)]
1406 [ValidateNotNullOrEmpty()]
1407 [string]$TemplatePath,
1408
1409 [Parameter(Mandatory = $true)]
1410 [hashtable]$CollectionManifest
1411 )
1412
1413 $result = @{
1414 Mismatches = @()
1415 IsConsistent = $true
1416 }
1417
1418 if (-not (Test-Path $TemplatePath)) {
1419 $result.Mismatches += @{
1420 Field = 'file'
1421 Template = $TemplatePath
1422 Manifest = 'N/A'
1423 Message = "Template file not found: $TemplatePath"
1424 }
1425 $result.IsConsistent = $false
1426 return $result
1427 }
1428
1429 try {
1430 $template = Get-Content -Path $TemplatePath -Raw | ConvertFrom-Json
1431 }
1432 catch {
1433 $result.Mismatches += @{
1434 Field = 'file'
1435 Template = $TemplatePath
1436 Manifest = 'N/A'
1437 Message = "Failed to parse template: $($_.Exception.Message)"
1438 }
1439 $result.IsConsistent = $false
1440 return $result
1441 }
1442
1443 $fieldsToCheck = @('name', 'displayName', 'description')
1444 foreach ($field in $fieldsToCheck) {
1445 $templateValue = $null
1446 $manifestValue = $null
1447
1448 if ($template.PSObject.Properties[$field]) {
1449 $templateValue = $template.$field
1450 }
1451 if ($CollectionManifest.ContainsKey($field)) {
1452 $manifestValue = $CollectionManifest[$field]
1453 }
1454
1455 if ($null -ne $templateValue -and $null -ne $manifestValue -and $templateValue -ne $manifestValue) {
1456 $result.Mismatches += @{
1457 Field = $field
1458 Template = $templateValue
1459 Manifest = $manifestValue
1460 Message = "$field diverges: template='$templateValue' manifest='$manifestValue'"
1461 }
1462 $result.IsConsistent = $false
1463 }
1464 }
1465
1466 return $result
1467}
1468
1469function Invoke-PrepareExtension {
1470 <#
1471 .SYNOPSIS
1472 Orchestrates VS Code extension preparation with full error handling.
1473 .DESCRIPTION
1474 Executes the complete preparation workflow: validates paths, discovers
1475 agents/prompts/instructions, updates package.json, and handles changelog.
1476 Returns a result object instead of using exit codes.
1477 .PARAMETER ExtensionDirectory
1478 Absolute path to the extension directory containing package.json.
1479 .PARAMETER RepoRoot
1480 Absolute path to the repository root directory.
1481 .PARAMETER Channel
1482 Release channel controlling maturity filter ('Stable' or 'PreRelease').
1483 .PARAMETER ChangelogPath
1484 Optional path to changelog file to include.
1485 .PARAMETER DryRun
1486 When specified, shows what would be done without making changes.
1487 .OUTPUTS
1488 Hashtable with Success, Version, AgentCount, PromptCount,
1489 InstructionCount, SkillCount, and ErrorMessage properties.
1490 #>
1491 [CmdletBinding()]
1492 [OutputType([hashtable])]
1493 param(
1494 [Parameter(Mandatory = $true)]
1495 [ValidateNotNullOrEmpty()]
1496 [string]$ExtensionDirectory,
1497
1498 [Parameter(Mandatory = $true)]
1499 [ValidateNotNullOrEmpty()]
1500 [string]$RepoRoot,
1501
1502 [Parameter(Mandatory = $false)]
1503 [ValidateSet('Stable', 'PreRelease')]
1504 [string]$Channel = 'Stable',
1505
1506 [Parameter(Mandatory = $false)]
1507 [string]$ChangelogPath = "",
1508
1509 [Parameter(Mandatory = $false)]
1510 [switch]$DryRun,
1511
1512 [Parameter(Mandatory = $false)]
1513 [string]$Collection = ""
1514 )
1515
1516 # Derive paths
1517 $GitHubDir = Join-Path $RepoRoot ".github"
1518 $PackageJsonPath = Join-Path $ExtensionDirectory "package.json"
1519
1520 # Generate collection package files from root collection manifests.
1521 # This ensures extension/package.json and extension/package.*.json exist
1522 # with the correct version from the template before any reads occur.
1523 try {
1524 $generated = Invoke-ExtensionCollectionsGeneration -RepoRoot $RepoRoot
1525 Write-Host "Generated $($generated.Count) collection package file(s)" -ForegroundColor Green
1526 }
1527 catch {
1528 return New-PrepareResult -Success $false -ErrorMessage "Package generation failed: $($_.Exception.Message)"
1529 }
1530
1531 # Validate required paths exist (package.json now guaranteed by generation)
1532 $pathValidation = Test-PathsExist -ExtensionDir $ExtensionDirectory `
1533 -PackageJsonPath $PackageJsonPath `
1534 -GitHubDir $GitHubDir
1535 if (-not $pathValidation.IsValid) {
1536 $missingPaths = $pathValidation.MissingPaths -join ', '
1537 return New-PrepareResult -Success $false -ErrorMessage "Required paths not found: $missingPaths"
1538 }
1539
1540 # Read and parse package.json
1541 try {
1542 $packageJsonContent = Get-Content -Path $PackageJsonPath -Raw
1543 $packageJson = $packageJsonContent | ConvertFrom-Json
1544 }
1545 catch {
1546 return New-PrepareResult -Success $false -ErrorMessage "Failed to parse package.json at '$PackageJsonPath'. Check the file for JSON syntax errors. Underlying error: $($_.Exception.Message)"
1547 }
1548
1549 # Validate version field
1550 if (-not $packageJson.PSObject.Properties['version']) {
1551 return New-PrepareResult -Success $false -ErrorMessage "package.json does not contain a 'version' field"
1552 }
1553 $version = $packageJson.version
1554 if ($version -notmatch '^\d+\.\d+\.\d+$') {
1555 return New-PrepareResult -Success $false -ErrorMessage "Invalid version format in package.json: $version"
1556 }
1557
1558 # Get allowed maturities for channel
1559 $allowedMaturities = Get-AllowedMaturities -Channel $Channel
1560
1561 Write-Host "`n=== Prepare Extension ===" -ForegroundColor Cyan
1562 Write-Host "Extension Directory: $ExtensionDirectory"
1563 Write-Host "Repository Root: $RepoRoot"
1564 Write-Host "Channel: $Channel"
1565 Write-Host "Allowed Maturities: $($allowedMaturities -join ', ')"
1566 Write-Host "Version: $version"
1567 if ($DryRun) {
1568 Write-Host "[DRY RUN] No changes will be made" -ForegroundColor Yellow
1569 }
1570
1571 # Load collection manifest if specified
1572 $collectionManifest = $null
1573 $collectionArtifactNames = $null
1574 $collectionMaturities = @{}
1575 $collectionRequires = @{}
1576
1577 if ($Collection -and $Collection -ne "") {
1578 $collectionManifest = Get-CollectionManifest -CollectionPath $Collection
1579 Write-Host "Collection: $($collectionManifest.displayName) ($($collectionManifest.id))"
1580
1581 $artifactCollectionManifest = $collectionManifest
1582 if (-not $artifactCollectionManifest.ContainsKey('items') -or @($artifactCollectionManifest.items).Count -eq 0) {
1583 # When the manifest lacks items (e.g., a generated JSON template),
1584 # resolve from the root YAML collection by ID.
1585 $rootCollectionPath = Join-Path $RepoRoot "collections/$($collectionManifest.id).collection.yml"
1586 if (Test-Path $rootCollectionPath) {
1587 $artifactCollectionManifest = Get-CollectionManifest -CollectionPath $rootCollectionPath
1588 Write-Host "Using root collection for items: $rootCollectionPath"
1589 }
1590 else {
1591 Write-Warning "No root collection found for '$($collectionManifest.id)' at $rootCollectionPath"
1592 }
1593 }
1594
1595 # Check collection-level maturity eligibility
1596 $collectionEligibility = Test-CollectionMaturityEligible -CollectionManifest $collectionManifest -Channel $Channel
1597 if (-not $collectionEligibility.IsEligible) {
1598 Write-Host "`n⏭️ $($collectionEligibility.Reason)" -ForegroundColor Yellow
1599 return New-PrepareResult -Success $true -Version $version
1600 }
1601
1602 $collectionMaturity = if ($collectionManifest.ContainsKey('maturity')) { $collectionManifest['maturity'] } else { 'stable' }
1603 Write-Host "Collection maturity: $collectionMaturity"
1604
1605 # Build collection maturity map and channel-filtered artifact names
1606 $collectionMaturities = @{}
1607 $collectionRequires = @{}
1608
1609 if ($artifactCollectionManifest.ContainsKey('items')) {
1610 foreach ($item in $artifactCollectionManifest.items) {
1611 if (-not $item.ContainsKey('kind') -or -not $item.ContainsKey('path')) {
1612 continue
1613 }
1614
1615 $itemKind = [string]$item.kind
1616 $itemPath = [string]$item.path
1617 $artifactKey = Get-CollectionArtifactKey -Kind $itemKind -Path $itemPath
1618 $effectiveMaturity = Resolve-CollectionItemMaturity -Maturity $item.maturity
1619 if (-not $collectionMaturities.ContainsKey("${itemKind}s") -or $null -eq $collectionMaturities["${itemKind}s"]) {
1620 $collectionMaturities["${itemKind}s"] = @{}
1621 }
1622 $collectionMaturities["${itemKind}s"][$artifactKey] = $effectiveMaturity
1623
1624 if ($item.ContainsKey('requires') -and $item.requires) {
1625 if (-not $collectionRequires.ContainsKey("${itemKind}s") -or $null -eq $collectionRequires["${itemKind}s"]) {
1626 $collectionRequires["${itemKind}s"] = @{}
1627 }
1628 $collectionRequires["${itemKind}s"][$artifactKey] = $item.requires
1629 }
1630 }
1631 }
1632
1633 $collectionArtifactNames = Get-CollectionArtifacts -Collection $artifactCollectionManifest -AllowedMaturities $allowedMaturities
1634
1635 # Resolve handoff dependencies (agents only)
1636 if (@($collectionArtifactNames.Agents).Count -gt 0) {
1637 $agentsDir = Join-Path $GitHubDir "agents"
1638 $expandedAgents = Resolve-HandoffDependencies -SeedAgents $collectionArtifactNames.Agents -AgentsDir $agentsDir
1639 $collectionArtifactNames.Agents = $expandedAgents
1640 }
1641
1642 # Resolve requires dependencies
1643 $resolvedNames = Resolve-RequiresDependencies -ArtifactNames @{
1644 agents = $collectionArtifactNames.Agents
1645 prompts = $collectionArtifactNames.Prompts
1646 instructions = $collectionArtifactNames.Instructions
1647 skills = $collectionArtifactNames.Skills
1648 } -AllowedMaturities $allowedMaturities -CollectionRequires $collectionRequires -CollectionMaturities $collectionMaturities
1649
1650 $collectionArtifactNames = @{
1651 Agents = $resolvedNames.Agents
1652 Prompts = $resolvedNames.Prompts
1653 Instructions = $resolvedNames.Instructions
1654 Skills = $resolvedNames.Skills
1655 }
1656 }
1657
1658 # Discover artifacts
1659 $discoveryAllowedMaturities = if ($null -ne $collectionArtifactNames) {
1660 @('stable', 'preview', 'experimental', 'deprecated')
1661 }
1662 else {
1663 $allowedMaturities
1664 }
1665
1666 $agentsDir = Join-Path $GitHubDir "agents"
1667 $agentResult = Get-DiscoveredAgents -AgentsDir $agentsDir -AllowedMaturities $discoveryAllowedMaturities -ExcludedAgents @()
1668 $chatAgents = $agentResult.Agents
1669 $excludedAgents = $agentResult.Skipped
1670
1671 Write-Host "`n--- Chat Agents ---" -ForegroundColor Green
1672 Write-Host "Found $($chatAgents.Count) agent(s) matching criteria"
1673 if ($excludedAgents.Count -gt 0) {
1674 Write-Host "Excluded $($excludedAgents.Count) agent(s) due to maturity filter" -ForegroundColor Yellow
1675 }
1676
1677 # Discover prompts
1678 $promptsDir = Join-Path $GitHubDir "prompts"
1679 $promptResult = Get-DiscoveredPrompts -PromptsDir $promptsDir -GitHubDir $GitHubDir -AllowedMaturities $discoveryAllowedMaturities
1680 $chatPrompts = $promptResult.Prompts
1681 $excludedPrompts = $promptResult.Skipped
1682
1683 Write-Host "`n--- Chat Prompts ---" -ForegroundColor Green
1684 Write-Host "Found $($chatPrompts.Count) prompt(s) matching criteria"
1685 if ($excludedPrompts.Count -gt 0) {
1686 Write-Host "Excluded $($excludedPrompts.Count) prompt(s) due to maturity filter" -ForegroundColor Yellow
1687 }
1688
1689 # Discover instructions
1690 $instructionsDir = Join-Path $GitHubDir "instructions"
1691 $instructionResult = Get-DiscoveredInstructions -InstructionsDir $instructionsDir -GitHubDir $GitHubDir -AllowedMaturities $discoveryAllowedMaturities
1692 $chatInstructions = $instructionResult.Instructions
1693 $excludedInstructions = $instructionResult.Skipped
1694
1695 Write-Host "`n--- Chat Instructions ---" -ForegroundColor Green
1696 Write-Host "Found $($chatInstructions.Count) instruction(s) matching criteria"
1697 if ($excludedInstructions.Count -gt 0) {
1698 Write-Host "Excluded $($excludedInstructions.Count) instruction(s) due to maturity filter" -ForegroundColor Yellow
1699 }
1700
1701 # Discover skills
1702 $skillsDir = Join-Path $GitHubDir "skills"
1703 $skillResult = Get-DiscoveredSkills -SkillsDir $skillsDir -AllowedMaturities $discoveryAllowedMaturities
1704 $chatSkills = $skillResult.Skills
1705 $excludedSkills = $skillResult.Skipped
1706
1707 Write-Host "`n--- Chat Skills ---" -ForegroundColor Green
1708 Write-Host "Found $($chatSkills.Count) skill(s) matching criteria"
1709 if ($excludedSkills.Count -gt 0) {
1710 Write-Host "Excluded $($excludedSkills.Count) skill(s) due to maturity filter" -ForegroundColor Yellow
1711 }
1712
1713 # Apply collection filtering to discovered artifacts
1714 if ($null -ne $collectionArtifactNames) {
1715 $chatAgents = @($chatAgents | Where-Object { $collectionArtifactNames.Agents -contains $_.name })
1716 $chatPrompts = @($chatPrompts | Where-Object { $collectionArtifactNames.Prompts -contains $_.name })
1717 $instrBaseNames = @($collectionArtifactNames.Instructions | ForEach-Object { ($_ -split '/')[-1] })
1718 $chatInstructions = @($chatInstructions | Where-Object {
1719 $instrBaseName = $_.name -replace '-instructions$', ''
1720 $instrBaseNames -contains $instrBaseName
1721 })
1722 $chatSkills = @($chatSkills | Where-Object { $collectionArtifactNames.Skills -contains $_.name })
1723
1724 Write-Host "`n--- Collection Filtering ---" -ForegroundColor Magenta
1725 Write-Host "Agents after filter: $($chatAgents.Count)"
1726 Write-Host "Prompts after filter: $($chatPrompts.Count)"
1727 Write-Host "Instructions after filter: $($chatInstructions.Count)"
1728 Write-Host "Skills after filter: $($chatSkills.Count)"
1729 }
1730
1731 # Apply collection template when building a non-default collection
1732 if ($null -ne $collectionManifest -and $collectionManifest.id -ne 'hve-core') {
1733 $collectionId = $collectionManifest.id
1734 $templatePath = Join-Path $ExtensionDirectory "package.$collectionId.json"
1735 if (-not (Test-Path $templatePath)) {
1736 return New-PrepareResult -Success $false -ErrorMessage "Collection template not found: $templatePath"
1737 }
1738
1739 # Validate template consistency against collection manifest
1740 $consistency = Test-TemplateConsistency -TemplatePath $templatePath -CollectionManifest $collectionManifest
1741 if (-not $consistency.IsConsistent) {
1742 Write-Host "`n--- Template Consistency Warnings ---" -ForegroundColor Yellow
1743 foreach ($mismatch in $consistency.Mismatches) {
1744 Write-Warning "Template/manifest mismatch: $($mismatch.Message)"
1745 Write-CIAnnotation -Message "Template/manifest mismatch ($collectionId): $($mismatch.Message)" -Level Warning
1746 }
1747 }
1748
1749 # Back up canonical package.json for later restore
1750 $backupPath = Join-Path $ExtensionDirectory "package.json.bak"
1751 Copy-Item -Path $PackageJsonPath -Destination $backupPath -Force
1752
1753 # Copy collection template over package.json
1754 Copy-Item -Path $templatePath -Destination $PackageJsonPath -Force
1755
1756 # Re-read template as the working package.json
1757 $packageJson = Get-Content -Path $PackageJsonPath -Raw | ConvertFrom-Json
1758 Write-Host "Applied collection template: package.$collectionId.json" -ForegroundColor Green
1759 }
1760
1761 # Update package.json with generated contributes
1762 $packageJson = Update-PackageJsonContributes -PackageJson $packageJson `
1763 -ChatAgents $chatAgents `
1764 -ChatPromptFiles $chatPrompts `
1765 -ChatInstructions $chatInstructions `
1766 -ChatSkills $chatSkills
1767
1768 # Write updated package.json
1769 if (-not $DryRun) {
1770 $packageJson | ConvertTo-Json -Depth 10 | Set-Content -Path $PackageJsonPath -Encoding UTF8NoBOM
1771 Write-Host "`nUpdated package.json with discovered artifacts" -ForegroundColor Green
1772 }
1773 else {
1774 Write-Host "`n[DRY RUN] Would update package.json with discovered artifacts" -ForegroundColor Yellow
1775 }
1776
1777 # Handle changelog
1778 if ($ChangelogPath -and (Test-Path $ChangelogPath)) {
1779 $destChangelog = Join-Path $ExtensionDirectory "CHANGELOG.md"
1780 if (-not $DryRun) {
1781 Copy-Item -Path $ChangelogPath -Destination $destChangelog -Force
1782 Write-Host "Copied changelog to extension directory" -ForegroundColor Green
1783 }
1784 else {
1785 Write-Host "[DRY RUN] Would copy changelog to extension directory" -ForegroundColor Yellow
1786 }
1787 }
1788 elseif ($ChangelogPath) {
1789 Write-Warning "Changelog path specified but file not found: $ChangelogPath"
1790 }
1791
1792 Write-Host "`n=== Preparation Complete ===" -ForegroundColor Cyan
1793
1794 return New-PrepareResult -Success $true `
1795 -Version $version `
1796 -AgentCount $chatAgents.Count `
1797 -PromptCount $chatPrompts.Count `
1798 -InstructionCount $chatInstructions.Count `
1799 -SkillCount $chatSkills.Count
1800}
1801
1802#endregion Pure Functions
1803
1804#region Main Execution
1805if ($MyInvocation.InvocationName -ne '.') {
1806 try {
1807 # Verify PowerShell-Yaml module is available
1808 if (-not (Get-Module -ListAvailable -Name PowerShell-Yaml)) {
1809 throw "Required module 'PowerShell-Yaml' is not installed."
1810 }
1811 Import-Module PowerShell-Yaml -ErrorAction Stop
1812
1813 # Resolve paths using $MyInvocation (must stay in entry point)
1814 $ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
1815 $RepoRoot = (Get-Item "$ScriptDir/../..").FullName
1816 $ExtensionDir = Join-Path $RepoRoot "extension"
1817
1818 # Resolve changelog path if provided
1819 $resolvedChangelogPath = ""
1820 if ($ChangelogPath) {
1821 $resolvedChangelogPath = if ([System.IO.Path]::IsPathRooted($ChangelogPath)) {
1822 $ChangelogPath
1823 }
1824 else {
1825 Join-Path $RepoRoot $ChangelogPath
1826 }
1827 }
1828
1829 # Default to hve-core collection when no collection is specified.
1830 # package.json is identity-mapped to the hve-core collection, so the
1831 # default build must apply hve-core filtering rather than including all
1832 # artifacts (hve-core-all behavior). Use -Collection with
1833 # hve-core-all.collection.yml explicitly to include everything.
1834 if (-not $Collection) {
1835 $Collection = Join-Path $RepoRoot 'collections/hve-core.collection.yml'
1836 }
1837
1838 Write-Host "📦 HVE Core Extension Preparer" -ForegroundColor Cyan
1839 Write-Host "==============================" -ForegroundColor Cyan
1840 Write-Host " Channel: $Channel" -ForegroundColor Cyan
1841 Write-Host " Collection: $Collection" -ForegroundColor Cyan
1842 Write-Host ""
1843
1844 # Call orchestration function
1845 $result = Invoke-PrepareExtension `
1846 -ExtensionDirectory $ExtensionDir `
1847 -RepoRoot $RepoRoot `
1848 -Channel $Channel `
1849 -ChangelogPath $resolvedChangelogPath `
1850 -DryRun:$DryRun `
1851 -Collection $Collection
1852
1853 if (-not $result.Success) {
1854 throw $result.ErrorMessage
1855 }
1856
1857 Write-Host ""
1858 Write-Host "🎉 Done!" -ForegroundColor Green
1859 Write-Host ""
1860 Write-Host "📊 Summary:" -ForegroundColor Cyan
1861 Write-Host " Agents: $($result.AgentCount)"
1862 Write-Host " Prompts: $($result.PromptCount)"
1863 Write-Host " Instructions: $($result.InstructionCount)"
1864 Write-Host " Skills: $($result.SkillCount)"
1865 Write-Host " Version: $($result.Version)"
1866
1867 exit 0
1868 }
1869 catch {
1870 Write-Error -ErrorAction Continue "Prepare-Extension failed: $($_.Exception.Message)"
1871 Write-CIAnnotation -Message $_.Exception.Message -Level Error
1872 exit 1
1873 }
1874}
1875#endregion Main Execution
1876