microsoft/hve-core

Public

mirrored from https://github.com/microsoft/hve-coreAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
feat/1873-devcontainer

Branches

Tags

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

Clone

HTTPS

Download ZIP

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#>
38function 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#>
81function 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#>
121function 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#>
156function 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#>
182function 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#>
214function 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#>
271function 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#>
318function 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#>
389function 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
490Export-ModuleMember -Function Get-AgentActivationFingerprint
491