microsoft/hve-core
Publicmirrored from https://github.com/microsoft/hve-coreAvailable
scripts/agents/activation-harness/Get-AgentActivationFingerprint.psm1
490lines · modecode
| 1 | # Copyright (c) Microsoft Corporation. |
| 2 | # SPDX-License-Identifier: MIT |
| 3 | |
| 4 | # Get-AgentActivationFingerprint.psm1 |
| 5 | # |
| 6 | # Purpose: Compute deterministic activation fingerprint (byte counts + SHA256) |
| 7 | # for a custom agent file across canonical activation scenarios. |
| 8 | # Used by the activation-harness Pester suite to assert cold-start |
| 9 | # byte budgets and dispatch-table loading behavior for the ADR |
| 10 | # Creator agent (and any future agent reusing this contract). |
| 11 | # Author: HVE Core Team |
| 12 | |
| 13 | #Requires -Version 7.0 |
| 14 | |
| 15 | #region Internal Helpers |
| 16 | |
| 17 | <# |
| 18 | .SYNOPSIS |
| 19 | Resolves a `#file:` directive's relative path to an absolute path. |
| 20 | |
| 21 | .DESCRIPTION |
| 22 | `#file:` directives in agent or instruction bodies use paths relative to |
| 23 | the file containing the directive. This helper normalizes the reference |
| 24 | against the source file's parent directory and returns the absolute path |
| 25 | when it exists on disk. |
| 26 | |
| 27 | .PARAMETER Reference |
| 28 | The raw reference text captured from the `#file:` directive |
| 29 | (e.g. `../../instructions/project-planning/adr-identity.instructions.md`). |
| 30 | |
| 31 | .PARAMETER SourcePath |
| 32 | Absolute path of the file that contained the directive. |
| 33 | |
| 34 | .OUTPUTS |
| 35 | [string] Absolute path to the referenced file, or $null when it does not |
| 36 | resolve to an existing file. |
| 37 | #> |
| 38 | function Resolve-FileReferencePath { |
| 39 | [CmdletBinding()] |
| 40 | [OutputType([string])] |
| 41 | param( |
| 42 | [Parameter(Mandatory = $true)] |
| 43 | [ValidateNotNullOrEmpty()] |
| 44 | [string]$Reference, |
| 45 | |
| 46 | [Parameter(Mandatory = $true)] |
| 47 | [ValidateNotNullOrEmpty()] |
| 48 | [string]$SourcePath |
| 49 | ) |
| 50 | |
| 51 | $sourceDir = Split-Path -Parent $SourcePath |
| 52 | $combined = Join-Path -Path $sourceDir -ChildPath $Reference |
| 53 | try { |
| 54 | $resolved = (Resolve-Path -LiteralPath $combined -ErrorAction Stop).Path |
| 55 | } catch { |
| 56 | return $null |
| 57 | } |
| 58 | |
| 59 | if (Test-Path -LiteralPath $resolved -PathType Leaf) { |
| 60 | return $resolved |
| 61 | } |
| 62 | return $null |
| 63 | } |
| 64 | |
| 65 | <# |
| 66 | .SYNOPSIS |
| 67 | Extracts every `#file:` directive payload from a string body. |
| 68 | |
| 69 | .DESCRIPTION |
| 70 | Returns the relative-path text following each `#file:` token. Strips a |
| 71 | trailing punctuation character (`.`, `,`, `;`, `:`, `)`, `]`, `>`) so |
| 72 | references embedded in prose ("see #file:foo.md.") do not inherit the |
| 73 | closing punctuation. |
| 74 | |
| 75 | .PARAMETER Body |
| 76 | The text to scan. |
| 77 | |
| 78 | .OUTPUTS |
| 79 | [string[]] Distinct, order-preserved relative paths. |
| 80 | #> |
| 81 | function Get-FileDirectiveReferences { |
| 82 | [CmdletBinding()] |
| 83 | [OutputType([string[]])] |
| 84 | param( |
| 85 | [Parameter(Mandatory = $true)] |
| 86 | [AllowEmptyString()] |
| 87 | [string]$Body |
| 88 | ) |
| 89 | |
| 90 | $regexMatches = [regex]::Matches($Body, '#file:([^\s\)\]\>]+)') |
| 91 | $result = [System.Collections.Generic.List[string]]::new() |
| 92 | $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) |
| 93 | foreach ($m in $regexMatches) { |
| 94 | $ref = $m.Groups[1].Value.TrimEnd('.', ',', ';', ':') |
| 95 | if ($seen.Add($ref)) { |
| 96 | [void]$result.Add($ref) |
| 97 | } |
| 98 | } |
| 99 | return $result.ToArray() |
| 100 | } |
| 101 | |
| 102 | <# |
| 103 | .SYNOPSIS |
| 104 | Extracts every `read_file`-style dispatch reference from a string body. |
| 105 | |
| 106 | .DESCRIPTION |
| 107 | The ADR Creator agent body's Lifecycle Dispatch Tables cite on-demand |
| 108 | reads using the literal pattern `` `read_file` `<repo-relative-path>` ``. |
| 109 | This helper captures each backtick-quoted path that immediately follows |
| 110 | a `` `read_file` `` token, strips any trailing `#anchor` fragment so a |
| 111 | SKILL.md anchor (e.g. `SKILL.md#frame`) resolves to the underlying file, |
| 112 | and returns distinct, order-preserved paths. Unlike `#file:` directives, |
| 113 | these paths are repo-root-relative. |
| 114 | |
| 115 | .PARAMETER Body |
| 116 | The text to scan. |
| 117 | |
| 118 | .OUTPUTS |
| 119 | [string[]] Distinct, order-preserved repo-relative paths. |
| 120 | #> |
| 121 | function Get-ReadFileReferences { |
| 122 | [CmdletBinding()] |
| 123 | [OutputType([string[]])] |
| 124 | param( |
| 125 | [Parameter(Mandatory = $true)] |
| 126 | [AllowEmptyString()] |
| 127 | [string]$Body |
| 128 | ) |
| 129 | |
| 130 | $regexMatches = [regex]::Matches($Body, '`read_file`\s+`([^`]+)`') |
| 131 | $result = [System.Collections.Generic.List[string]]::new() |
| 132 | $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) |
| 133 | foreach ($m in $regexMatches) { |
| 134 | $ref = $m.Groups[1].Value |
| 135 | $hashIdx = $ref.IndexOf('#') |
| 136 | if ($hashIdx -ge 0) { $ref = $ref.Substring(0, $hashIdx) } |
| 137 | $ref = $ref.Trim() |
| 138 | if ($ref -and $seen.Add($ref)) { |
| 139 | [void]$result.Add($ref) |
| 140 | } |
| 141 | } |
| 142 | return $result.ToArray() |
| 143 | } |
| 144 | |
| 145 | <# |
| 146 | .SYNOPSIS |
| 147 | Splits an agent file's text into frontmatter and body. |
| 148 | |
| 149 | .PARAMETER Content |
| 150 | Full agent file text. |
| 151 | |
| 152 | .OUTPUTS |
| 153 | [hashtable] @{ Frontmatter = <string>; Body = <string> }. Frontmatter is |
| 154 | empty when no `---` delimited block is present at the top of the file. |
| 155 | #> |
| 156 | function Split-AgentContent { |
| 157 | [CmdletBinding()] |
| 158 | [OutputType([hashtable])] |
| 159 | param( |
| 160 | [Parameter(Mandatory = $true)] |
| 161 | [AllowEmptyString()] |
| 162 | [string]$Content |
| 163 | ) |
| 164 | |
| 165 | if ($Content -match '(?s)\A---\r?\n(.*?)\r?\n---\r?\n(.*)\z') { |
| 166 | return @{ Frontmatter = $Matches[1]; Body = $Matches[2] } |
| 167 | } |
| 168 | return @{ Frontmatter = ''; Body = $Content } |
| 169 | } |
| 170 | |
| 171 | <# |
| 172 | .SYNOPSIS |
| 173 | Reads frontmatter `applyTo` globs as a string array. |
| 174 | |
| 175 | .PARAMETER Frontmatter |
| 176 | Frontmatter YAML text (without surrounding `---` lines). |
| 177 | |
| 178 | .OUTPUTS |
| 179 | [string[]] Glob patterns parsed from the `applyTo:` line. Empty array |
| 180 | when the field is absent. |
| 181 | #> |
| 182 | function Get-ApplyToGlobs { |
| 183 | [CmdletBinding()] |
| 184 | [OutputType([string[]])] |
| 185 | param( |
| 186 | [Parameter(Mandatory = $true)] |
| 187 | [AllowEmptyString()] |
| 188 | [string]$Frontmatter |
| 189 | ) |
| 190 | |
| 191 | if ($Frontmatter -match "(?m)^applyTo:\s*['""]?([^'""\r\n]+)['""]?\s*$") { |
| 192 | return ($Matches[1] -split ',') | ForEach-Object { $_.Trim() } | Where-Object { $_ } |
| 193 | } |
| 194 | return @() |
| 195 | } |
| 196 | |
| 197 | <# |
| 198 | .SYNOPSIS |
| 199 | Discovers instruction files whose `applyTo` would auto-attach inside the |
| 200 | `.copilot-tracking/adr-plans/` working directory. |
| 201 | |
| 202 | .DESCRIPTION |
| 203 | Scans `.github/instructions/**/*.instructions.md` under the repository |
| 204 | root, parses each file's frontmatter `applyTo`, and returns the paths |
| 205 | whose globs include any of the canonical ADR working-directory tokens |
| 206 | (`adr-plans` or `docs/planning/adrs`). |
| 207 | |
| 208 | .PARAMETER RepoRoot |
| 209 | Absolute path to the repository root. |
| 210 | |
| 211 | .OUTPUTS |
| 212 | [string[]] Sorted, distinct absolute paths. |
| 213 | #> |
| 214 | function Get-AdrApplyToInstructions { |
| 215 | [CmdletBinding()] |
| 216 | [OutputType([string[]])] |
| 217 | param( |
| 218 | [Parameter(Mandatory = $true)] |
| 219 | [ValidateNotNullOrEmpty()] |
| 220 | [string]$RepoRoot |
| 221 | ) |
| 222 | |
| 223 | $instructionsRoot = Join-Path -Path $RepoRoot -ChildPath '.github/instructions' |
| 224 | if (-not (Test-Path -LiteralPath $instructionsRoot)) { |
| 225 | return @() |
| 226 | } |
| 227 | |
| 228 | $tokens = @('adr-plans', 'docs/planning/adrs') |
| 229 | $matched = [System.Collections.Generic.List[string]]::new() |
| 230 | Get-ChildItem -LiteralPath $instructionsRoot -Recurse -Filter '*.instructions.md' -File | |
| 231 | ForEach-Object { |
| 232 | $text = Get-Content -LiteralPath $_.FullName -Raw -Encoding UTF8 |
| 233 | $split = Split-AgentContent -Content $text |
| 234 | $globs = Get-ApplyToGlobs -Frontmatter $split.Frontmatter |
| 235 | foreach ($g in $globs) { |
| 236 | foreach ($t in $tokens) { |
| 237 | if ($g -like "*$t*") { |
| 238 | [void]$matched.Add($_.FullName) |
| 239 | break |
| 240 | } |
| 241 | } |
| 242 | } |
| 243 | } |
| 244 | |
| 245 | return ($matched | Sort-Object -Unique) |
| 246 | } |
| 247 | |
| 248 | <# |
| 249 | .SYNOPSIS |
| 250 | Extracts file paths cited inside the agent body's Lifecycle Dispatch |
| 251 | Tables (Table A and Table B) for a given lifecycle phase token. |
| 252 | |
| 253 | .DESCRIPTION |
| 254 | The ADR Creator agent body uses two markdown tables — Table A (lifecycle |
| 255 | phases: Frame / Decide / Govern) and Table B (adopt-template steps: |
| 256 | Ingest / Normalize / Derive Questions / Fill / Govern). Cells contain |
| 257 | `#file:`-style references to the on-demand reads required by that phase |
| 258 | or step. This helper returns the set of references whose row label |
| 259 | matches the supplied `PhaseToken` (case-insensitive substring match). |
| 260 | |
| 261 | .PARAMETER Body |
| 262 | Agent body text. |
| 263 | |
| 264 | .PARAMETER PhaseToken |
| 265 | Token to match against the leading column of a table row (e.g. 'Govern', |
| 266 | 'Ingest'). |
| 267 | |
| 268 | .OUTPUTS |
| 269 | [string[]] Distinct relative-path references from matching rows. |
| 270 | #> |
| 271 | function Get-DispatchTableReferences { |
| 272 | [CmdletBinding()] |
| 273 | [OutputType([string[]])] |
| 274 | param( |
| 275 | [Parameter(Mandatory = $true)] |
| 276 | [AllowEmptyString()] |
| 277 | [string]$Body, |
| 278 | |
| 279 | [Parameter(Mandatory = $true)] |
| 280 | [ValidateNotNullOrEmpty()] |
| 281 | [string]$PhaseToken |
| 282 | ) |
| 283 | |
| 284 | $result = [System.Collections.Generic.List[string]]::new() |
| 285 | $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) |
| 286 | $rowPattern = '(?im)^\|\s*[^|]*' + [regex]::Escape($PhaseToken) + '[^|]*\|.*$' |
| 287 | foreach ($row in [regex]::Matches($Body, $rowPattern)) { |
| 288 | foreach ($ref in (Get-FileDirectiveReferences -Body $row.Value)) { |
| 289 | if ($seen.Add($ref)) { |
| 290 | [void]$result.Add($ref) |
| 291 | } |
| 292 | } |
| 293 | foreach ($ref in (Get-ReadFileReferences -Body $row.Value)) { |
| 294 | if ($seen.Add($ref)) { |
| 295 | [void]$result.Add($ref) |
| 296 | } |
| 297 | } |
| 298 | foreach ($m in [regex]::Matches($row.Value, '`([^`]+\.(?:md|py|ps1|psm1|psd1|json|ya?ml|sh|js|ts|txt))(?:#[^`]*)?`')) { |
| 299 | $ref = $m.Groups[1].Value.Trim() |
| 300 | if ($ref -and $ref.Contains('/') -and $seen.Add($ref)) { |
| 301 | [void]$result.Add($ref) |
| 302 | } |
| 303 | } |
| 304 | } |
| 305 | return $result.ToArray() |
| 306 | } |
| 307 | |
| 308 | <# |
| 309 | .SYNOPSIS |
| 310 | Returns the UTF-8 byte count of a file with line endings normalized to LF. |
| 311 | |
| 312 | .DESCRIPTION |
| 313 | Reads the file as UTF-8 text, strips a leading BOM if present, normalizes |
| 314 | CRLF to LF, and returns the resulting UTF-8 byte length. Ensures the |
| 315 | activation fingerprint is identical across Windows (CRLF working trees) |
| 316 | and Linux/macOS CI runners (LF working trees). |
| 317 | #> |
| 318 | function Get-NormalizedFileByteCount { |
| 319 | [CmdletBinding()] |
| 320 | [OutputType([int])] |
| 321 | param( |
| 322 | [Parameter(Mandatory = $true)] |
| 323 | [ValidateNotNullOrEmpty()] |
| 324 | [string]$Path |
| 325 | ) |
| 326 | |
| 327 | $text = [System.IO.File]::ReadAllText($Path, [System.Text.Encoding]::UTF8) |
| 328 | if ($text.Length -gt 0 -and $text[0] -eq [char]0xFEFF) { |
| 329 | $text = $text.Substring(1) |
| 330 | } |
| 331 | $normalized = $text -replace "`r`n", "`n" |
| 332 | return [int][System.Text.Encoding]::UTF8.GetByteCount($normalized) |
| 333 | } |
| 334 | |
| 335 | #endregion |
| 336 | |
| 337 | #region Public Surface |
| 338 | |
| 339 | <# |
| 340 | .SYNOPSIS |
| 341 | Computes a deterministic activation fingerprint for a custom agent file. |
| 342 | |
| 343 | .DESCRIPTION |
| 344 | Models four canonical activation scenarios for VS Code Copilot custom |
| 345 | agents and returns the bytes that would be loaded into context plus a |
| 346 | SHA256 hash of the deterministic file-path / byte-count tuple. |
| 347 | |
| 348 | Scenarios: |
| 349 | * CleanWorkspace - cold start: agent file plus every `#file:` directive |
| 350 | in the agent body. Models the case where no open editor file matches |
| 351 | any `applyTo` glob. |
| 352 | * SteadyState - working inside `.copilot-tracking/adr-plans/`: |
| 353 | CleanWorkspace plus every instruction file whose frontmatter |
| 354 | `applyTo` glob covers the ADR working directories. |
| 355 | * GovernEntry - SteadyState plus references in Lifecycle Dispatch |
| 356 | Table rows tagged 'Govern'. |
| 357 | * AdoptTemplate - SteadyState plus references in Lifecycle Dispatch |
| 358 | Table rows tagged 'Ingest', 'Normalize', 'Derive', or 'Fill' |
| 359 | (the adopt-template Table B steps). |
| 360 | |
| 361 | The returned hashtable is JSON-serializable and feeds the harness |
| 362 | baseline file at scripts/agents/activation-harness/baseline.json. |
| 363 | |
| 364 | .PARAMETER AgentPath |
| 365 | Absolute or repo-relative path to the agent .agent.md file. |
| 366 | |
| 367 | .PARAMETER ScenarioName |
| 368 | One of CleanWorkspace, SteadyState, GovernEntry, AdoptTemplate. |
| 369 | |
| 370 | .PARAMETER RepoRoot |
| 371 | Optional absolute path to the repository root. When omitted, resolves |
| 372 | three levels above this module file. |
| 373 | |
| 374 | .OUTPUTS |
| 375 | [hashtable] @{ |
| 376 | ScenarioName = <string> |
| 377 | AgentBytes = <int> |
| 378 | ColdStartBytes = <int> # total bytes for the scenario |
| 379 | LoadedFiles = @(@{Path=<repo-relative>; Bytes=<int>}) |
| 380 | Hash = <string> # lowercase hex SHA256 |
| 381 | } |
| 382 | |
| 383 | .EXAMPLE |
| 384 | Import-Module ./Get-AgentActivationFingerprint.psm1 |
| 385 | Get-AgentActivationFingerprint ` |
| 386 | -AgentPath '.github/agents/project-planning/adr-creation.agent.md' ` |
| 387 | -ScenarioName 'CleanWorkspace' |
| 388 | #> |
| 389 | function Get-AgentActivationFingerprint { |
| 390 | [CmdletBinding()] |
| 391 | [OutputType([hashtable])] |
| 392 | param( |
| 393 | [Parameter(Mandatory = $true)] |
| 394 | [ValidateNotNullOrEmpty()] |
| 395 | [string]$AgentPath, |
| 396 | |
| 397 | [Parameter(Mandatory = $true)] |
| 398 | [ValidateSet('CleanWorkspace', 'SteadyState', 'GovernEntry', 'AdoptTemplate')] |
| 399 | [string]$ScenarioName, |
| 400 | |
| 401 | [Parameter(Mandatory = $false)] |
| 402 | [string]$RepoRoot |
| 403 | ) |
| 404 | |
| 405 | if ([string]::IsNullOrWhiteSpace($RepoRoot)) { |
| 406 | $RepoRoot = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot '..' '..' '..')).Path |
| 407 | } else { |
| 408 | $RepoRoot = (Resolve-Path -LiteralPath $RepoRoot).Path |
| 409 | } |
| 410 | |
| 411 | $agentFullPath = (Resolve-Path -LiteralPath $AgentPath).Path |
| 412 | $agentBytes = Get-NormalizedFileByteCount -Path $agentFullPath |
| 413 | $agentText = Get-Content -LiteralPath $agentFullPath -Raw -Encoding UTF8 |
| 414 | $split = Split-AgentContent -Content $agentText |
| 415 | |
| 416 | $loaded = [System.Collections.Generic.Dictionary[string, int]]::new([System.StringComparer]::OrdinalIgnoreCase) |
| 417 | $loaded[$agentFullPath] = $agentBytes |
| 418 | |
| 419 | foreach ($ref in Get-FileDirectiveReferences -Body $split.Body) { |
| 420 | $resolved = Resolve-FileReferencePath -Reference $ref -SourcePath $agentFullPath |
| 421 | if ($resolved -and -not $loaded.ContainsKey($resolved)) { |
| 422 | $loaded[$resolved] = Get-NormalizedFileByteCount -Path $resolved |
| 423 | } |
| 424 | } |
| 425 | |
| 426 | if ($ScenarioName -in @('SteadyState', 'GovernEntry', 'AdoptTemplate')) { |
| 427 | foreach ($path in Get-AdrApplyToInstructions -RepoRoot $RepoRoot) { |
| 428 | if (-not $loaded.ContainsKey($path)) { |
| 429 | $loaded[$path] = Get-NormalizedFileByteCount -Path $path |
| 430 | } |
| 431 | } |
| 432 | } |
| 433 | |
| 434 | if ($ScenarioName -eq 'GovernEntry') { |
| 435 | foreach ($ref in Get-DispatchTableReferences -Body $split.Body -PhaseToken 'Govern') { |
| 436 | $candidate = Join-Path -Path $RepoRoot -ChildPath $ref |
| 437 | if (Test-Path -LiteralPath $candidate -PathType Leaf) { |
| 438 | $resolved = (Resolve-Path -LiteralPath $candidate).Path |
| 439 | if (-not $loaded.ContainsKey($resolved)) { |
| 440 | $loaded[$resolved] = Get-NormalizedFileByteCount -Path $resolved |
| 441 | } |
| 442 | } |
| 443 | } |
| 444 | } |
| 445 | |
| 446 | if ($ScenarioName -eq 'AdoptTemplate') { |
| 447 | foreach ($token in @('Ingest', 'Normalize', 'Derive', 'Fill')) { |
| 448 | foreach ($ref in Get-DispatchTableReferences -Body $split.Body -PhaseToken $token) { |
| 449 | $candidate = Join-Path -Path $RepoRoot -ChildPath $ref |
| 450 | if (Test-Path -LiteralPath $candidate -PathType Leaf) { |
| 451 | $resolved = (Resolve-Path -LiteralPath $candidate).Path |
| 452 | if (-not $loaded.ContainsKey($resolved)) { |
| 453 | $loaded[$resolved] = Get-NormalizedFileByteCount -Path $resolved |
| 454 | } |
| 455 | } |
| 456 | } |
| 457 | } |
| 458 | } |
| 459 | |
| 460 | $orderedPaths = $loaded.Keys | Sort-Object |
| 461 | $loadedFilesList = @( |
| 462 | foreach ($abs in $orderedPaths) { |
| 463 | $rel = $abs.Substring($RepoRoot.Length).TrimStart('\', '/').Replace('\', '/') |
| 464 | [ordered]@{ Path = $rel; Bytes = $loaded[$abs] } |
| 465 | } |
| 466 | ) |
| 467 | |
| 468 | $coldStartBytes = ($loaded.Values | Measure-Object -Sum).Sum |
| 469 | |
| 470 | $hashInput = ($loadedFilesList | ForEach-Object { "$($_.Path):$($_.Bytes)" }) -join '|' |
| 471 | $sha = [System.Security.Cryptography.SHA256]::Create() |
| 472 | try { |
| 473 | $hashBytes = $sha.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($hashInput)) |
| 474 | } finally { |
| 475 | $sha.Dispose() |
| 476 | } |
| 477 | $hash = -join ($hashBytes | ForEach-Object { $_.ToString('x2') }) |
| 478 | |
| 479 | return [ordered]@{ |
| 480 | ScenarioName = $ScenarioName |
| 481 | AgentBytes = $agentBytes |
| 482 | ColdStartBytes = [int]$coldStartBytes |
| 483 | LoadedFiles = $loadedFilesList |
| 484 | Hash = $hash |
| 485 | } |
| 486 | } |
| 487 | |
| 488 | #endregion |
| 489 | |
| 490 | Export-ModuleMember -Function Get-AgentActivationFingerprint |
| 491 | |