microsoft/hve-core
Publicmirrored fromhttps://github.com/microsoft/hve-coreAvailable
scripts/evals/Modules/AffectedAgents.psm1
373lines · modecode
| 1 | # Copyright (c) Microsoft Corporation. |
| 2 | # SPDX-License-Identifier: MIT |
| 3 | #Requires -Version 7.0 |
| 4 | |
| 5 | <# |
| 6 | .SYNOPSIS |
| 7 | Resolve changed artifact paths to the set of parent-agent slugs whose |
| 8 | per-agent eval surface they affect. |
| 9 | |
| 10 | .DESCRIPTION |
| 11 | Module form of the slug-resolution logic consumed by |
| 12 | `Get-ChangedAIArtifact.ps1` (which embeds the result as the `affectedAgents` |
| 13 | field of the artifact manifest) and downstream Vally dispatch. |
| 14 | |
| 15 | Resolution rules per input path: |
| 16 | 1. Parent agent (`*.agent.md` whose YAML frontmatter does NOT set |
| 17 | `user-invocable: false`) -> returns `<slug>`. |
| 18 | 2. Subagent (`*.agent.md` whose frontmatter sets `user-invocable: false`) |
| 19 | -> returns every parent slug that references the subagent under the |
| 20 | dependency map `subagents[]`. |
| 21 | 3. Stimulus YAML (`evals/agent-behavior/stimuli/<slug>.yml`) |
| 22 | -> returns `<slug>`. |
| 23 | 4. Instruction (`.github/instructions/<...>.instructions.md`) |
| 24 | -> returns every parent slug that references the file under the |
| 25 | dependency map `instructions[]`. |
| 26 | 5. Skill (`.github/skills/<...>/<...>.md`) |
| 27 | -> returns every parent slug that references the skill under the |
| 28 | dependency map `skills[]`. |
| 29 | 6. Anything else -> contributes nothing. |
| 30 | |
| 31 | DD-09 compliance: parent-vs-subagent classification reads the agent file's |
| 32 | frontmatter `user-invocable` key. The historical `/subagents/` path |
| 33 | convention is informational only. No hardcoded allowlist participates. |
| 34 | |
| 35 | The helper silently regenerates `logs/agent-dependency-map.json` when the |
| 36 | file is missing or older than the newest `.agent.md` under `.github/agents/`. |
| 37 | #> |
| 38 | |
| 39 | Set-StrictMode -Version Latest |
| 40 | $ErrorActionPreference = 'Stop' |
| 41 | |
| 42 | # Module-scoped cache for frontmatter classification, keyed by absolute file path. |
| 43 | $script:FrontmatterCache = @{} |
| 44 | |
| 45 | function Resolve-RepoRoot { |
| 46 | [CmdletBinding()] |
| 47 | [OutputType([string])] |
| 48 | param([string]$Override) |
| 49 | |
| 50 | if ($Override) { return (Resolve-Path -LiteralPath $Override).Path } |
| 51 | try { |
| 52 | $root = (& git rev-parse --show-toplevel 2>$null).Trim() |
| 53 | if ($LASTEXITCODE -eq 0 -and $root) { return $root } |
| 54 | } catch { |
| 55 | Write-Verbose "git rev-parse failed: $($_.Exception.Message)" |
| 56 | } |
| 57 | return (Get-Location).Path |
| 58 | } |
| 59 | |
| 60 | function ConvertTo-NormalizedPath { |
| 61 | [CmdletBinding()] |
| 62 | [OutputType([string])] |
| 63 | param( |
| 64 | [Parameter(Mandatory)] [string]$RepoRoot, |
| 65 | [Parameter(Mandatory)] [string]$Path |
| 66 | ) |
| 67 | |
| 68 | if ([string]::IsNullOrWhiteSpace($Path)) { return '' } |
| 69 | $candidate = $Path -replace '\\', '/' |
| 70 | if ([System.IO.Path]::IsPathRooted($candidate)) { |
| 71 | $rootFull = ([System.IO.Path]::GetFullPath($RepoRoot)) -replace '\\', '/' |
| 72 | $rootFull = $rootFull.TrimEnd('/') |
| 73 | $pathFull = ([System.IO.Path]::GetFullPath($candidate)) -replace '\\', '/' |
| 74 | if ($pathFull.StartsWith($rootFull + '/', [System.StringComparison]::OrdinalIgnoreCase)) { |
| 75 | return $pathFull.Substring($rootFull.Length + 1) |
| 76 | } |
| 77 | } |
| 78 | return $candidate.TrimStart('/') |
| 79 | } |
| 80 | |
| 81 | function Test-IsAgentArtifactPath { |
| 82 | [CmdletBinding()] |
| 83 | [OutputType([bool])] |
| 84 | param([Parameter(Mandatory)] [string]$RelativePath) |
| 85 | |
| 86 | return ($RelativePath -match '(?i)^\.github/agents/.+\.agent\.md$') |
| 87 | } |
| 88 | |
| 89 | function Test-IsParentAgentByFrontmatter { |
| 90 | <# |
| 91 | .SYNOPSIS |
| 92 | Determine whether an agent file is a parent (user-invocable) under DD-09. |
| 93 | |
| 94 | .DESCRIPTION |
| 95 | Reads the YAML frontmatter `user-invocable` key from the agent file on disk. |
| 96 | Returns $true when the key is absent or evaluates to anything other than |
| 97 | the boolean/string value `false`. When the file is missing on disk (for |
| 98 | example a deletion), the caller-supplied $DepMap is consulted as a |
| 99 | fallback: a slug present in the dep-map is assumed to be a parent agent |
| 100 | only when there is no subagent reverse-mapping evidence; otherwise |
| 101 | classification defers to the subagent code path. |
| 102 | |
| 103 | Results are cached per absolute path to keep repeat lookups O(1). |
| 104 | #> |
| 105 | [CmdletBinding()] |
| 106 | [OutputType([bool])] |
| 107 | param( |
| 108 | [Parameter(Mandatory)] [string]$RepoRoot, |
| 109 | [Parameter(Mandatory)] [string]$RelativePath |
| 110 | ) |
| 111 | |
| 112 | $absPath = Join-Path -Path $RepoRoot -ChildPath $RelativePath |
| 113 | if ($script:FrontmatterCache.ContainsKey($absPath)) { |
| 114 | return [bool]$script:FrontmatterCache[$absPath] |
| 115 | } |
| 116 | |
| 117 | if (-not (Test-Path -LiteralPath $absPath -PathType Leaf)) { |
| 118 | # File is missing (likely a delete-side path). Treat as parent so the |
| 119 | # eval surface remains visible; subagent reverse-lookup will run too |
| 120 | # and naturally produce no extra slugs when the file truly is a parent. |
| 121 | $script:FrontmatterCache[$absPath] = $true |
| 122 | return $true |
| 123 | } |
| 124 | |
| 125 | try { |
| 126 | $raw = [System.IO.File]::ReadAllText($absPath) |
| 127 | } catch { |
| 128 | Write-Verbose "Failed to read '$absPath': $($_.Exception.Message)" |
| 129 | $script:FrontmatterCache[$absPath] = $true |
| 130 | return $true |
| 131 | } |
| 132 | |
| 133 | if ($raw -notmatch '(?ms)^---\s*\r?\n(.*?)\r?\n---\s*(?:\r?\n|$)') { |
| 134 | $script:FrontmatterCache[$absPath] = $true |
| 135 | return $true |
| 136 | } |
| 137 | |
| 138 | # Parse only the `user-invocable` line; avoids a full YAML dependency. |
| 139 | $block = $matches[1] |
| 140 | foreach ($line in ($block -split "\r?\n")) { |
| 141 | if ($line -match '^\s*user-invocable\s*:\s*(?<val>.+?)\s*$') { |
| 142 | $val = $matches['val'].Trim().Trim("'", '"').ToLowerInvariant() |
| 143 | $isParent = ($val -ne 'false') |
| 144 | $script:FrontmatterCache[$absPath] = $isParent |
| 145 | return $isParent |
| 146 | } |
| 147 | } |
| 148 | |
| 149 | $script:FrontmatterCache[$absPath] = $true |
| 150 | return $true |
| 151 | } |
| 152 | |
| 153 | function Get-StimulusSlug { |
| 154 | [CmdletBinding()] |
| 155 | [OutputType([string])] |
| 156 | param([Parameter(Mandatory)] [string]$RelativePath) |
| 157 | |
| 158 | if ($RelativePath -match '(?i)^evals/agent-behavior/stimuli/(?<slug>[^/]+)\.ya?ml$') { |
| 159 | return $matches['slug'] |
| 160 | } |
| 161 | return $null |
| 162 | } |
| 163 | |
| 164 | function Test-IsIndirectArtifactPath { |
| 165 | [CmdletBinding()] |
| 166 | [OutputType([bool])] |
| 167 | param([Parameter(Mandatory)] [string]$RelativePath) |
| 168 | |
| 169 | if ($RelativePath -match '(?i)^\.github/instructions/.+\.instructions\.md$') { return $true } |
| 170 | if ($RelativePath -match '(?i)^\.github/skills/.+\.md$') { return $true } |
| 171 | return $false |
| 172 | } |
| 173 | |
| 174 | function Update-DepMapIfStale { |
| 175 | [CmdletBinding()] |
| 176 | [OutputType([string])] |
| 177 | param( |
| 178 | [Parameter(Mandatory)] [string]$RepoRoot, |
| 179 | [Parameter(Mandatory)] [string]$DepMapPath |
| 180 | ) |
| 181 | |
| 182 | $regenerate = -not (Test-Path -LiteralPath $DepMapPath -PathType Leaf) |
| 183 | if (-not $regenerate) { |
| 184 | $mapMTime = (Get-Item -LiteralPath $DepMapPath).LastWriteTimeUtc |
| 185 | $agentsRoot = Join-Path -Path $RepoRoot -ChildPath '.github/agents' |
| 186 | if (Test-Path -LiteralPath $agentsRoot -PathType Container) { |
| 187 | $newest = Get-ChildItem -Path $agentsRoot -Recurse -Filter '*.agent.md' -File -ErrorAction SilentlyContinue | |
| 188 | Sort-Object LastWriteTimeUtc -Descending | Select-Object -First 1 |
| 189 | if ($newest -and $newest.LastWriteTimeUtc -gt $mapMTime) { $regenerate = $true } |
| 190 | } |
| 191 | } |
| 192 | |
| 193 | if ($regenerate) { |
| 194 | $depMapScript = Join-Path -Path $RepoRoot -ChildPath 'scripts/evals/Get-AgentDependencyMap.ps1' |
| 195 | if (Test-Path -LiteralPath $depMapScript -PathType Leaf) { |
| 196 | Write-Verbose "Refreshing agent dependency map: $DepMapPath" |
| 197 | $outDir = Split-Path -Parent $DepMapPath |
| 198 | if (-not (Test-Path -LiteralPath $outDir -PathType Container)) { |
| 199 | New-Item -ItemType Directory -Path $outDir -Force | Out-Null |
| 200 | } |
| 201 | & pwsh -NoProfile -File $depMapScript -RepoRoot $RepoRoot -OutputPath $DepMapPath | Out-Null |
| 202 | } |
| 203 | } |
| 204 | |
| 205 | return $DepMapPath |
| 206 | } |
| 207 | |
| 208 | function Read-DepMap { |
| 209 | [CmdletBinding()] |
| 210 | [OutputType([pscustomobject])] |
| 211 | param([Parameter(Mandatory)] [string]$DepMapPath) |
| 212 | |
| 213 | if (-not (Test-Path -LiteralPath $DepMapPath -PathType Leaf)) { return $null } |
| 214 | try { |
| 215 | return (Get-Content -LiteralPath $DepMapPath -Raw -Encoding utf8 | ConvertFrom-Json) |
| 216 | } catch { |
| 217 | Write-Verbose "Failed to parse '$DepMapPath': $($_.Exception.Message)" |
| 218 | return $null |
| 219 | } |
| 220 | } |
| 221 | |
| 222 | function Build-ReverseIndex { |
| 223 | [CmdletBinding()] |
| 224 | [OutputType([hashtable])] |
| 225 | param( |
| 226 | [Parameter(Mandatory)] [pscustomobject]$DepMap, |
| 227 | [Parameter(Mandatory)] [ValidateSet('instructions', 'skills', 'subagents')] [string]$Field |
| 228 | ) |
| 229 | |
| 230 | $index = @{} |
| 231 | foreach ($prop in $DepMap.PSObject.Properties) { |
| 232 | $slug = $prop.Name |
| 233 | $entry = $prop.Value |
| 234 | if (-not $entry.PSObject.Properties.Name.Contains($Field)) { continue } |
| 235 | $refs = @($entry.$Field) |
| 236 | foreach ($ref in $refs) { |
| 237 | if ([string]::IsNullOrWhiteSpace($ref)) { continue } |
| 238 | $key = ($ref -replace '\\', '/').TrimStart('/') |
| 239 | if (-not $index.ContainsKey($key)) { |
| 240 | $index[$key] = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) |
| 241 | } |
| 242 | $null = $index[$key].Add($slug) |
| 243 | } |
| 244 | } |
| 245 | return $index |
| 246 | } |
| 247 | |
| 248 | function Get-AffectedAgentSlugs { |
| 249 | <# |
| 250 | .SYNOPSIS |
| 251 | Map a set of changed file paths to the parent-agent slugs whose evals they |
| 252 | affect. |
| 253 | |
| 254 | .PARAMETER ChangedFiles |
| 255 | Workspace-relative or absolute file paths to classify. Empty or |
| 256 | non-artifact paths contribute nothing. |
| 257 | |
| 258 | .PARAMETER RepoRoot |
| 259 | Repository root. Defaults to `git rev-parse --show-toplevel`. |
| 260 | |
| 261 | .PARAMETER DepMapPath |
| 262 | Override the dependency map location. Defaults to |
| 263 | `<RepoRoot>/logs/agent-dependency-map.json`. |
| 264 | |
| 265 | .PARAMETER SkipDepMapRefresh |
| 266 | Skip the auto-refresh step when the dep-map is stale. Used by tests that |
| 267 | seed a hand-built map. |
| 268 | |
| 269 | .OUTPUTS |
| 270 | [string[]] sorted, de-duplicated parent-agent slugs. |
| 271 | #> |
| 272 | [CmdletBinding()] |
| 273 | [OutputType([string[]])] |
| 274 | param( |
| 275 | [Parameter(Mandatory)] |
| 276 | [AllowEmptyCollection()] |
| 277 | [string[]]$ChangedFiles, |
| 278 | |
| 279 | [string]$RepoRoot, |
| 280 | [string]$DepMapPath, |
| 281 | [switch]$SkipDepMapRefresh |
| 282 | ) |
| 283 | |
| 284 | $resolvedRoot = Resolve-RepoRoot -Override $RepoRoot |
| 285 | if (-not $DepMapPath) { |
| 286 | $DepMapPath = Join-Path -Path $resolvedRoot -ChildPath 'logs/agent-dependency-map.json' |
| 287 | } |
| 288 | |
| 289 | $normalized = [System.Collections.Generic.List[string]]::new() |
| 290 | foreach ($p in $ChangedFiles) { |
| 291 | $rel = ConvertTo-NormalizedPath -RepoRoot $resolvedRoot -Path $p |
| 292 | if ($rel) { $normalized.Add($rel) } |
| 293 | } |
| 294 | |
| 295 | if ($normalized.Count -eq 0) { return ,[string[]]@() } |
| 296 | |
| 297 | $result = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) |
| 298 | |
| 299 | # Pass 1: direct parent agents and stimulus YAMLs. Track agent paths that |
| 300 | # are NOT parents under DD-09 so Pass 2 can expand them via subagents[]. |
| 301 | $subagentCandidates = [System.Collections.Generic.List[string]]::new() |
| 302 | $needsDepMap = $false |
| 303 | foreach ($rel in $normalized) { |
| 304 | if (Test-IsAgentArtifactPath -RelativePath $rel) { |
| 305 | if (Test-IsParentAgentByFrontmatter -RepoRoot $resolvedRoot -RelativePath $rel) { |
| 306 | $slug = [System.IO.Path]::GetFileName($rel) -replace '\.agent\.md$', '' |
| 307 | [void]$result.Add($slug) |
| 308 | } |
| 309 | else { |
| 310 | $subagentCandidates.Add($rel) |
| 311 | $needsDepMap = $true |
| 312 | } |
| 313 | continue |
| 314 | } |
| 315 | $stimSlug = Get-StimulusSlug -RelativePath $rel |
| 316 | if ($stimSlug) { |
| 317 | [void]$result.Add($stimSlug) |
| 318 | continue |
| 319 | } |
| 320 | if (Test-IsIndirectArtifactPath -RelativePath $rel) { |
| 321 | $needsDepMap = $true |
| 322 | } |
| 323 | } |
| 324 | |
| 325 | if (-not $needsDepMap) { |
| 326 | return ,[string[]](@($result | Sort-Object)) |
| 327 | } |
| 328 | |
| 329 | if (-not $SkipDepMapRefresh) { |
| 330 | Update-DepMapIfStale -RepoRoot $resolvedRoot -DepMapPath $DepMapPath | Out-Null |
| 331 | } |
| 332 | |
| 333 | $depMap = Read-DepMap -DepMapPath $DepMapPath |
| 334 | if ($null -eq $depMap) { return ,[string[]](@($result | Sort-Object)) } |
| 335 | |
| 336 | $instructionIndex = Build-ReverseIndex -DepMap $depMap -Field 'instructions' |
| 337 | $skillIndex = Build-ReverseIndex -DepMap $depMap -Field 'skills' |
| 338 | $subagentIndex = Build-ReverseIndex -DepMap $depMap -Field 'subagents' |
| 339 | |
| 340 | foreach ($rel in $subagentCandidates) { |
| 341 | if ($subagentIndex.ContainsKey($rel)) { |
| 342 | foreach ($slug in $subagentIndex[$rel]) { [void]$result.Add($slug) } |
| 343 | } |
| 344 | } |
| 345 | |
| 346 | foreach ($rel in $normalized) { |
| 347 | if ($rel -match '(?i)^\.github/instructions/.+\.instructions\.md$') { |
| 348 | if ($instructionIndex.ContainsKey($rel)) { |
| 349 | foreach ($slug in $instructionIndex[$rel]) { [void]$result.Add($slug) } |
| 350 | } |
| 351 | continue |
| 352 | } |
| 353 | if ($rel -match '(?i)^\.github/skills/.+\.md$') { |
| 354 | if ($skillIndex.ContainsKey($rel)) { |
| 355 | foreach ($slug in $skillIndex[$rel]) { [void]$result.Add($slug) } |
| 356 | } |
| 357 | } |
| 358 | } |
| 359 | |
| 360 | return ,[string[]](@($result | Sort-Object)) |
| 361 | } |
| 362 | |
| 363 | function Clear-AffectedAgentsCache { |
| 364 | <# |
| 365 | .SYNOPSIS |
| 366 | Reset the frontmatter classification cache. Intended for tests. |
| 367 | #> |
| 368 | [CmdletBinding()] |
| 369 | param() |
| 370 | $script:FrontmatterCache = @{} |
| 371 | } |
| 372 | |
| 373 | Export-ModuleMember -Function Get-AffectedAgentSlugs, Clear-AffectedAgentsCache |
| 374 | |