skills/contributor-profile/scripts/ContributorProfileReader.ps1
|
<#
Read path of the contributor-profile Skill. Decision record 0028. Defines functions only and writes nothing: the location rule, the strict schema 1 reader, the identity rule, the Knowledge areas declaration, and the calibration sentence. The SessionStart and PostToolUse hooks dot-source this file, and ContributorProfileCommon.ps1 builds every writer on it, so one implementation serves every entry point. Keep this file ASCII: Windows PowerShell 5.1 runs the VS Code hooks and reads a script without a byte-order mark in the system code page. #> function Get-ContributorProfileLimit { <# The caps of schema 1 and of the declaration, in one place. #> [CmdletBinding()] [OutputType([hashtable])] param () @{ ProfileBytes = 65536 Entries = 16 Aliases = 8 Areas = 200 AreaNameLength = 48 DeclarationBytes = 65536 DeclaredAreas = 16 JsonDepth = 16 GitMilliseconds = 2000 StepMilliseconds = 3000 LockMilliseconds = 5000 } } function ConvertFrom-ContributorJson { <# Parses RFC 8259 JSON strictly and identically in Windows PowerShell 5.1 and PowerShell 7. ConvertFrom-Json differs between the two: PowerShell 7 turns ISO 8601 strings into dates and rejects case-only duplicate keys, which would give the hooks and the commands different reason codes for one file. Every node keeps its JSON type: @{ T = 'o' | 'a' | 's' | 'n' | 'b' | 'z' }. Objects keep duplicate keys in order, numbers keep their raw text. A syntax error throws FormatException; nesting deeper than the cap throws InvalidDataException, because the JSON is valid but no schema 1 file is that deep. The parser is iterative, so a hostile file cannot exhaust the call stack. #> [CmdletBinding()] [OutputType([hashtable])] param ( [Parameter(Mandatory = $true)] [AllowEmptyString()] [System.String] $Text, [Parameter()] [System.Int32] $MaximumDepth = 16 ) $tokenPattern = '\G[ \t\r\n]*(?:(?<s>"(?:[^"\\\x00-\x1F]|\\(?:["\\/bfnrt]|u[0-9A-Fa-f]{4}))*")|(?<n>-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?)(?![0-9A-Za-z.+-])|(?<l>true|false|null)(?![0-9A-Za-z])|(?<p>[{}\[\]:,]))' $tokens = [System.Text.RegularExpressions.Regex]::Matches($Text, $tokenPattern) $consumed = 0 if ($tokens.Count -gt 0) { $last = $tokens[$tokens.Count - 1] $consumed = $last.Index + $last.Length } if ($Text.Substring($consumed) -notmatch '\A[ \t\r\n]*\z') { throw [System.FormatException]::new('invalid-json: unexpected character at offset ' + $consumed) } if ($tokens.Count -eq 0) { throw [System.FormatException]::new('invalid-json: empty document') } $unescape = [System.Text.RegularExpressions.MatchEvaluator] { param ($match) if ($match.Groups[1].Success) { return [string][char][System.Convert]::ToInt32($match.Groups[1].Value, 16) } switch -CaseSensitive ($match.Groups[2].Value) { 'b' { return [string][char]8 } 'f' { return [string][char]12 } 'n' { return [string][char]10 } 'r' { return [string][char]13 } 't' { return [string][char]9 } default { return $match.Groups[2].Value } } } $root = $null $rootSet = $false $stack = [System.Collections.Generic.List[hashtable]]::new() foreach ($token in $tokens) { $node = $null $punctuation = $null if ($token.Groups['s'].Success) { $inner = $token.Groups['s'].Value $inner = $inner.Substring(1, $inner.Length - 2) if ($inner.IndexOf([char]92) -ge 0) { $inner = [System.Text.RegularExpressions.Regex]::Replace($inner, '\\(?:u([0-9A-Fa-f]{4})|(["\\/bfnrt]))', $unescape) } $node = @{ T = 's'; V = $inner } } elseif ($token.Groups['n'].Success) { $node = @{ T = 'n'; V = $token.Groups['n'].Value } } elseif ($token.Groups['l'].Success) { switch ($token.Groups['l'].Value) { 'true' { $node = @{ T = 'b'; V = $true } } 'false' { $node = @{ T = 'b'; V = $false } } default { $node = @{ T = 'z'; V = $null } } } } else { $punctuation = $token.Groups['p'].Value } $frame = if ($stack.Count -gt 0) { $stack[$stack.Count - 1] } else { $null } if ($null -ne $frame -and $frame.Node.T -eq 'o' -and ($frame.State -eq 'start' -or $frame.State -eq 'comma')) { # An object waiting for a property name, or for its end when empty. if ($punctuation -eq '}' -and $frame.State -eq 'start') { $stack.RemoveAt($stack.Count - 1) $node = $frame.Node $punctuation = $null $frame = if ($stack.Count -gt 0) { $stack[$stack.Count - 1] } else { $null } } elseif ($null -ne $node -and $node.T -eq 's') { $frame.Key = $node.V $frame.State = 'key' continue } else { throw [System.FormatException]::new('invalid-json: expected a property name') } } elseif ($null -ne $frame -and $frame.Node.T -eq 'o' -and $frame.State -eq 'key') { if ($punctuation -ne ':') { throw [System.FormatException]::new('invalid-json: expected a colon') } $frame.State = 'colon' continue } elseif ($null -ne $frame -and $frame.State -eq 'value') { if ($punctuation -eq ',') { $frame.State = 'comma' continue } $closing = if ($frame.Node.T -eq 'o') { '}' } else { ']' } if ($punctuation -ne $closing) { throw [System.FormatException]::new('invalid-json: expected a comma or ' + $closing) } $stack.RemoveAt($stack.Count - 1) $node = $frame.Node $punctuation = $null $frame = if ($stack.Count -gt 0) { $stack[$stack.Count - 1] } else { $null } } elseif ($null -ne $frame -and $frame.Node.T -eq 'a' -and $frame.State -eq 'start' -and $punctuation -eq ']') { $stack.RemoveAt($stack.Count - 1) $node = $frame.Node $punctuation = $null $frame = if ($stack.Count -gt 0) { $stack[$stack.Count - 1] } else { $null } } if ($punctuation -eq '{' -or $punctuation -eq '[') { if ($stack.Count -ge $MaximumDepth) { throw [System.IO.InvalidDataException]::new('invalid-schema: nesting deeper than ' + $MaximumDepth) } if ($null -eq $frame -and $rootSet) { throw [System.FormatException]::new('invalid-json: more than one top-level value') } if ($null -ne $frame -and $frame.Node.T -eq 'o' -and $frame.State -ne 'colon') { throw [System.FormatException]::new('invalid-json: expected a property name') } $container = if ($punctuation -eq '{') { @{ T = 'o'; K = [System.Collections.Generic.List[string]]::new(); V = [System.Collections.Generic.List[object]]::new() } } else { @{ T = 'a'; V = [System.Collections.Generic.List[object]]::new() } } $stack.Add(@{ Node = $container; State = 'start'; Key = $null }) continue } if ($null -ne $punctuation) { throw [System.FormatException]::new('invalid-json: unexpected ' + $punctuation) } if ($null -eq $frame) { if ($rootSet) { throw [System.FormatException]::new('invalid-json: more than one top-level value') } $root = $node $rootSet = $true continue } if ($frame.Node.T -eq 'o') { if ($frame.State -ne 'colon') { throw [System.FormatException]::new('invalid-json: expected a property name') } $frame.Node.K.Add($frame.Key) $frame.Node.V.Add($node) } else { if ($frame.State -ne 'start' -and $frame.State -ne 'comma') { throw [System.FormatException]::new('invalid-json: expected a comma or ]') } $frame.Node.V.Add($node) } $frame.State = 'value' } if ($stack.Count -gt 0 -or -not $rootSet) { throw [System.FormatException]::new('invalid-json: unterminated document') } return $root } function ConvertTo-ContributorAreaName { <# Applies the area-name rule of Decision record 0028 to raw text: trim, Unicode NFC, then 1 to 48 characters of letters of any script with their combining marks, decimal digits, single spaces, and . + # / & ( ) -, starting with a letter, a digit, or a dot directly followed by a letter or a digit, so .NET passes (ruling A6). Returns the normalized name, or $null when the text breaks the rule. The profile and projectbrief.md share it. #> [CmdletBinding()] [OutputType([System.String])] param ( [Parameter()] [AllowNull()] [AllowEmptyString()] [System.String] $Name ) if ($null -eq $Name) { return $null } $normalized = $Name.Trim() try { $normalized = $normalized.Normalize([System.Text.NormalizationForm]::FormC) } catch { # An unpaired surrogate cannot be normalized and is not a letter. return $null } $surrogatePairs = [System.Text.RegularExpressions.Regex]::Matches($normalized, '[\uD800-\uDBFF][\uDC00-\uDFFF]').Count $length = $normalized.Length - $surrogatePairs if ($length -lt 1 -or $length -gt 48) { return $null } if ($normalized -cnotmatch '\A(?:[\p{L}\p{Nd}]|\.(?=[\p{L}\p{Nd}]))(?:[\p{L}\p{M}\p{Nd}.+#/&()-]| (?! ))*\z') { return $null } return $normalized } function Test-ContributorAreaName { <# True when a stored name already is its own normalized form under the area-name rule, so two stored spellings can never differ only by surrounding whitespace or Unicode normalization. #> [CmdletBinding()] [OutputType([System.Boolean])] param ( [Parameter()] [AllowNull()] [AllowEmptyString()] [System.String] $Name ) $normalized = ConvertTo-ContributorAreaName -Name $Name return ($null -ne $normalized -and [System.String]::Equals($normalized, $Name, [System.StringComparison]::Ordinal)) } function Test-ContributorUtcTimestamp { <# ISO 8601 in UTC with a Z designator, optionally with up to seven fractional digits, naming a real calendar instant. #> [CmdletBinding()] [OutputType([System.Boolean])] param ( [Parameter()] [AllowNull()] [AllowEmptyString()] [System.String] $Value ) if ([System.String]::IsNullOrEmpty($Value) -or $Value -cnotmatch '\A[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]{1,7})?Z\z') { return $false } $parsed = [System.DateTime]::MinValue return [System.DateTime]::TryParse( $Value, [System.Globalization.CultureInfo]::InvariantCulture, ([System.Globalization.DateTimeStyles]::AdjustToUniversal -bor [System.Globalization.DateTimeStyles]::AssumeUniversal), [ref] $parsed ) } function ConvertFrom-ContributorUtcTimestamp { [CmdletBinding()] [OutputType([System.DateTime])] param ( [Parameter(Mandatory = $true)] [System.String] $Value ) return [System.DateTime]::Parse( $Value, [System.Globalization.CultureInfo]::InvariantCulture, ([System.Globalization.DateTimeStyles]::AdjustToUniversal -bor [System.Globalization.DateTimeStyles]::AssumeUniversal) ) } function Test-ContributorAlias { <# An alias is an email address as git reports it: one @, no whitespace, control characters, quotes, angle brackets, or list separators, at most 254 characters. #> [CmdletBinding()] [OutputType([System.Boolean])] param ( [Parameter()] [AllowNull()] [AllowEmptyString()] [System.String] $Alias ) return (-not [System.String]::IsNullOrEmpty($Alias) -and $Alias.Length -le 254 -and $Alias -cmatch '\A[^\s\p{Cc}@"<>()\[\]\\,;:`]+@[^\s\p{Cc}@"<>()\[\]\\,;:`]+\z') } function Get-ContributorNodeMember { <# Returns the members of a parsed JSON object as a case-insensitive map, or throws invalid-schema when a name repeats in any letter case, is not one of the allowed names, or an allowed name is missing. Allowed names are matched exactly. Messages never repeat text from the file. #> [CmdletBinding()] [OutputType([System.Collections.Generic.Dictionary[string, object]])] param ( [Parameter(Mandatory = $true)] [hashtable] $Node, [Parameter(Mandatory = $true)] [System.String[]] $Allowed, [Parameter(Mandatory = $true)] [System.String] $Where ) if ($Node.T -ne 'o') { throw [System.IO.InvalidDataException]::new("invalid-schema: $Where is not an object") } # Ordinal and case-insensitive on purpose: a case-only duplicate is a # violation, and the result must not depend on the machine's culture. $members = [System.Collections.Generic.Dictionary[string, object]]::new([System.StringComparer]::OrdinalIgnoreCase) for ($index = 0; $index -lt $Node.K.Count; $index++) { $name = $Node.K[$index] if ($members.ContainsKey($name)) { throw [System.IO.InvalidDataException]::new("invalid-schema: $Where repeats a property name") } if ($Allowed -cnotcontains $name) { throw [System.IO.InvalidDataException]::new("invalid-schema: $Where has an unknown property") } $members[$name] = $Node.V[$index] } foreach ($name in $Allowed) { if (-not $members.ContainsKey($name)) { throw [System.IO.InvalidDataException]::new("invalid-schema: $Where lacks '$name'") } } return , $members } function ConvertTo-ContributorProfileModel { <# Validates a parsed document against schema 1 and returns the profile model. Any violation rejects the whole file: InvalidDataException for invalid-schema, NotSupportedException for unsupported-schema. The schema version is checked first, because the rules of another version are unknown here. #> [CmdletBinding()] [OutputType([System.Management.Automation.PSCustomObject])] param ( [Parameter(Mandatory = $true)] [hashtable] $Node ) $limit = Get-ContributorProfileLimit $fail = { param ([string] $Message) throw [System.IO.InvalidDataException]::new('invalid-schema: ' + $Message) } if ($Node.T -ne 'o') { & $fail 'the profile is not an object' } $versionIndex = $Node.K.IndexOf('schemaVersion') if ($versionIndex -lt 0 -or $Node.V[$versionIndex].T -ne 'n' -or $Node.V[$versionIndex].V -cnotmatch '\A(?:0|[1-9][0-9]*)\z') { & $fail 'schemaVersion is missing or not an integer' } if ($Node.V[$versionIndex].V -cne '1') { throw [System.NotSupportedException]::new('unsupported-schema: schemaVersion is not 1') } $root = Get-ContributorNodeMember -Node $Node -Allowed @('schemaVersion', 'contributors') -Where 'the profile' $list = $root['contributors'] if ($list.T -ne 'a' -or $list.V.Count -lt 1 -or $list.V.Count -gt $limit.Entries) { & $fail "contributors must hold 1 to $($limit.Entries) entries" } $ids = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) $allAliases = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) $defaultCount = 0 $entries = [System.Collections.Generic.List[object]]::new() for ($position = 0; $position -lt $list.V.Count; $position++) { $where = 'entry ' + ($position + 1) $member = Get-ContributorNodeMember -Node $list.V[$position] -Where $where -Allowed @( 'id', 'default', 'state', 'stateUpdatedUtc', 'interviewSnoozedUntilUtc', 'aliases', 'areas' ) $parsedId = [System.Guid]::Empty if ($member['id'].T -ne 's' -or -not [System.Guid]::TryParseExact($member['id'].V, 'D', [ref] $parsedId)) { & $fail "$where has no GUID id" } if (-not $ids.Add($member['id'].V)) { & $fail "$where repeats an id" } if ($member['default'].T -ne 'b') { & $fail "$where default is not a boolean" } if ($member['default'].V) { $defaultCount++ } if ($member['state'].T -ne 's' -or @('on', 'off') -cnotcontains $member['state'].V) { & $fail "$where state is not on or off" } if ($member['stateUpdatedUtc'].T -ne 's' -or -not (Test-ContributorUtcTimestamp -Value $member['stateUpdatedUtc'].V)) { & $fail "$where stateUpdatedUtc is not an ISO 8601 UTC time" } $snooze = $member['interviewSnoozedUntilUtc'] if (-not ($snooze.T -eq 'z' -or ($snooze.T -eq 's' -and (Test-ContributorUtcTimestamp -Value $snooze.V)))) { & $fail "$where interviewSnoozedUntilUtc is neither null nor an ISO 8601 UTC time" } $aliasNode = $member['aliases'] if ($aliasNode.T -ne 'a' -or $aliasNode.V.Count -gt $limit.Aliases) { & $fail "$where aliases must be an array of at most $($limit.Aliases) addresses" } $aliases = [System.Collections.Generic.List[string]]::new() foreach ($aliasValue in $aliasNode.V) { if ($aliasValue.T -ne 's' -or -not (Test-ContributorAlias -Alias $aliasValue.V)) { & $fail "$where has an alias that is not an email address" } if (-not $allAliases.Add($aliasValue.V)) { & $fail "$where repeats an alias that one entry already holds" } $aliases.Add($aliasValue.V) } $areaNode = $member['areas'] if ($areaNode.T -ne 'o' -or $areaNode.K.Count -gt $limit.Areas) { & $fail "$where areas must be an object of at most $($limit.Areas) Knowledge areas" } $areas = [System.Collections.Specialized.OrderedDictionary]::new([System.StringComparer]::InvariantCultureIgnoreCase) for ($areaIndex = 0; $areaIndex -lt $areaNode.K.Count; $areaIndex++) { $areaName = $areaNode.K[$areaIndex] if (-not (Test-ContributorAreaName -Name $areaName)) { & $fail "$where has a Knowledge area name that breaks the area-name rule" } if ($areas.Contains($areaName)) { & $fail "$where repeats a Knowledge area name" } $areaMember = Get-ContributorNodeMember -Node $areaNode.V[$areaIndex] -Allowed @('level', 'updatedUtc') -Where "a Knowledge area of $where" if ($areaMember['level'].T -ne 's' -or @('new', 'familiar', 'expert') -cnotcontains $areaMember['level'].V) { & $fail "$where has a level that is not new, familiar, or expert" } if ($areaMember['updatedUtc'].T -ne 's' -or -not (Test-ContributorUtcTimestamp -Value $areaMember['updatedUtc'].V)) { & $fail "$where has a Knowledge area updatedUtc that is not an ISO 8601 UTC time" } $areas[$areaName] = [pscustomobject] @{ Name = $areaName Level = $areaMember['level'].V UpdatedUtc = $areaMember['updatedUtc'].V } } $entries.Add([pscustomobject] @{ Id = $member['id'].V Default = [bool] $member['default'].V State = $member['state'].V StateUpdatedUtc = $member['stateUpdatedUtc'].V InterviewSnoozedUntilUtc = if ($snooze.T -eq 's') { $snooze.V } else { $null } Aliases = $aliases Areas = $areas }) } if ($defaultCount -gt 1) { & $fail 'more than one entry is marked default' } return [pscustomobject] @{ SchemaVersion = 1 Contributors = $entries } } # --- ContributorProfileReader part 4 --- function Resolve-ContributorProfileLocation { <# The location rule of Decision record 0028, shared by every reader and writer. Follows ~/.copilot/hooks (USERPROFILE before HOME, as the hook launchers do) to its link target; when the target's parent holds .copilotatelier.json, that parent is the Canonical target and the profile lives in its contributor folder. Otherwise the profile lives under LocalApplicationData on this machine only. There is no override. #> [CmdletBinding()] [OutputType([System.Management.Automation.PSCustomObject])] param ( [Parameter()] [AllowNull()] [AllowEmptyString()] [System.String] $UserHome, [Parameter()] [AllowNull()] [AllowEmptyString()] [System.String] $LocalApplicationData ) if ([System.String]::IsNullOrWhiteSpace($UserHome)) { $UserHome = @( [System.Environment]::GetEnvironmentVariable('USERPROFILE') [System.Environment]::GetEnvironmentVariable('HOME') [System.Environment]::GetFolderPath([System.Environment+SpecialFolder]::UserProfile) ) | Where-Object -FilterScript { -not [System.String]::IsNullOrWhiteSpace($_) } | Select-Object -First 1 } if ([System.String]::IsNullOrWhiteSpace($LocalApplicationData)) { $LocalApplicationData = @( [System.Environment]::GetEnvironmentVariable('LOCALAPPDATA') [System.Environment]::GetFolderPath([System.Environment+SpecialFolder]::LocalApplicationData) [System.IO.Path]::Combine([string] $UserHome, '.local', 'share') ) | Where-Object -FilterScript { -not [System.String]::IsNullOrWhiteSpace($_) } | Select-Object -First 1 } $hooksDirectory = [System.IO.Path]::Combine($UserHome, '.copilot', 'hooks') $canonicalTarget = $null try { $hooksItem = Get-Item -LiteralPath $hooksDirectory -Force -ErrorAction Stop $hooksTarget = $hooksDirectory if ($hooksItem.LinkType -eq 'Junction' -or $hooksItem.LinkType -eq 'SymbolicLink') { $linkTarget = [string] @($hooksItem.Target)[0] if (-not [System.IO.Path]::IsPathRooted($linkTarget)) { $linkTarget = [System.IO.Path]::Combine([System.IO.Path]::GetDirectoryName($hooksDirectory), $linkTarget) } $hooksTarget = [System.IO.Path]::GetFullPath($linkTarget) } $parent = [System.IO.Path]::GetDirectoryName($hooksTarget.TrimEnd([char[]] @([char]92, [char]47))) if ($parent -and [System.IO.File]::Exists([System.IO.Path]::Combine($parent, '.copilotatelier.json'))) { $canonicalTarget = $parent } } catch { $canonicalTarget = $null } if ($canonicalTarget) { $kind = 'canonical' $directory = [System.IO.Path]::Combine($canonicalTarget, 'contributor') } else { $kind = 'local' $directory = [System.IO.Path]::Combine($LocalApplicationData, 'CopilotAtelier', 'contributor') } $synced = $false if ($canonicalTarget) { foreach ($variable in 'OneDrive', 'OneDriveConsumer', 'OneDriveCommercial') { $oneDriveRoot = [System.Environment]::GetEnvironmentVariable($variable) if (-not [System.String]::IsNullOrWhiteSpace($oneDriveRoot)) { $prefix = $oneDriveRoot.TrimEnd([char[]] @([char]92, [char]47)) + [System.IO.Path]::DirectorySeparatorChar if ($canonicalTarget.StartsWith($prefix, [System.StringComparison]::OrdinalIgnoreCase)) { $synced = $true } } } } return [pscustomobject] @{ Kind = $kind Synced = $synced CanonicalTarget = $canonicalTarget ContributorDirectory = $directory ProfilePath = [System.IO.Path]::Combine($directory, 'profile.json') LockPath = [System.IO.Path]::Combine($directory, 'profile.lock') RecordPath = [System.IO.Path]::Combine($directory, 'registration.json') HooksDirectory = $hooksDirectory RegistrationPath = [System.IO.Path]::Combine($hooksDirectory, 'contributor-profile.json') RegistrationScript = [System.IO.Path]::Combine($hooksDirectory, 'scripts', 'Add-FamiliarityContext.ps1') } } function Test-ContributorPathInsideRepository { <# True when any ancestor directory of the path holds a .git directory or file, which also covers worktrees and submodules. With -ResolveLinks the ancestors of every junction or symbolic link on the way count as well, so a link cannot carry a write into a working tree; the writers pass it, while the hooks' read stays one attribute-free walk. No Familiarity level may land under a git working tree. #> [CmdletBinding()] [OutputType([System.Boolean])] param ( [Parameter(Mandatory = $true)] [System.String] $Path, [Parameter()] [System.Management.Automation.SwitchParameter] $ResolveLinks ) $pending = [System.Collections.Generic.Queue[string]]::new() $pending.Enqueue([System.IO.Path]::GetDirectoryName([System.IO.Path]::GetFullPath($Path))) $resolved = 0 while ($pending.Count -gt 0) { $directory = $pending.Dequeue() while (-not [System.String]::IsNullOrEmpty($directory)) { $marker = [System.IO.Path]::Combine($directory, '.git') if ([System.IO.Directory]::Exists($marker) -or [System.IO.File]::Exists($marker)) { return $true } # The cap ends a cycle of links. if ($ResolveLinks -and $resolved -lt 16) { $target = Get-ContributorLinkTarget -Path $directory if ($target) { $resolved++ $pending.Enqueue($target) } } $parent = [System.IO.Path]::GetDirectoryName($directory) if ($parent -eq $directory) { break } $directory = $parent } } return $false } function Get-ContributorLinkTarget { <# The full target path of a directory that is a junction or a symbolic link, else $null. The reparse-point attribute is checked first, so an ordinary directory costs one attribute query; a cloud placeholder is a reparse point without a link target and yields $null. #> [CmdletBinding()] [OutputType([System.String])] param ( [Parameter(Mandatory = $true)] [System.String] $Path ) try { $info = [System.IO.DirectoryInfo]::new($Path) if (-not $info.Exists -or -not ($info.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) { return $null } $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop if ($item.LinkType -ne 'Junction' -and $item.LinkType -ne 'SymbolicLink') { return $null } $target = [string] @($item.Target)[0] if ([System.String]::IsNullOrWhiteSpace($target)) { return $null } if (-not [System.IO.Path]::IsPathRooted($target)) { $target = [System.IO.Path]::Combine([System.IO.Path]::GetDirectoryName($Path), $target) } return [System.IO.Path]::GetFullPath($target) } catch { return $null } } function Test-ContributorFileLocal { <# False for a cloud placeholder whose data is not on this machine: the offline, recall-on-open, or recall-on-data-access attribute. Reading the attributes never downloads the file; reading its data would. #> [CmdletBinding()] [OutputType([System.Boolean])] param ( [Parameter(Mandatory = $true)] [System.Int64] $Attributes ) $remote = 0x1000 -bor 0x40000 -bor 0x400000 return (($Attributes -band $remote) -eq 0) } function Get-ContributorReasonCode { <# Maps an exception from the parser or the validator to its reason code. The code is the fixed prefix of the message, so wrapping across call boundaries cannot change it. #> [CmdletBinding()] [OutputType([System.String])] param ( [Parameter(Mandatory = $true)] [System.Exception] $Exception ) $current = $Exception while ($null -ne $current) { $match = [System.Text.RegularExpressions.Regex]::Match([string] $current.Message, '\A(invalid-json|invalid-schema|unsupported-schema):') if ($match.Success) { return $match.Groups[1].Value } $current = $current.InnerException } return 'read-error' } function Read-ContributorProfile { <# Reads and validates the profile at the location, or names exactly one reason code: inside-repository, not-local, too-large, invalid-json, invalid-schema, unsupported-schema, or read-error. A missing file is not an error. The data is read only after the path, attribute, and size checks pass, so a cloud placeholder is never downloaded. #> [CmdletBinding()] [OutputType([System.Management.Automation.PSCustomObject])] param ( [Parameter(Mandatory = $true)] [System.Object] $Location ) try { if (Test-ContributorPathInsideRepository -Path $Location.ProfilePath) { return [pscustomobject] @{ Exists = [System.IO.File]::Exists($Location.ProfilePath) ReasonCode = 'inside-repository' Detail = 'The profile path lies inside a git working tree.' Profile = $null } } } catch { return [pscustomobject] @{ Exists = $false ReasonCode = 'read-error' Detail = 'The profile path could not be checked: ' + $_.Exception.GetType().Name Profile = $null } } return Read-ContributorProfileData -Path $Location.ProfilePath } function Read-ContributorProfileData { <# Reads and validates one schema 1 file at any path, without the repository check: Import reads an exported file from wherever the contributor keeps it, and only writes are confined. #> [CmdletBinding()] [OutputType([System.Management.Automation.PSCustomObject])] param ( [Parameter(Mandatory = $true)] [System.String] $Path ) $result = [pscustomobject] @{ Exists = $false ReasonCode = $null Detail = $null Profile = $null } try { $file = [System.IO.FileInfo]::new($Path) if (-not $file.Exists) { return $result } $result.Exists = $true if (-not (Test-ContributorFileLocal -Attributes ([System.Int64] $file.Attributes))) { $result.ReasonCode = 'not-local' $result.Detail = 'The profile is a cloud placeholder whose data is not on this machine.' return $result } $limit = (Get-ContributorProfileLimit).ProfileBytes if ($file.Length -gt $limit) { $result.ReasonCode = 'too-large' $result.Detail = "The profile is larger than $limit bytes." return $result } $buffer = New-Object -TypeName 'System.Byte[]' -ArgumentList ($limit + 1) $count = 0 $stream = [System.IO.FileStream]::new($Path, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite) try { while ($count -le $limit) { $read = $stream.Read($buffer, $count, $buffer.Length - $count) if ($read -le 0) { break } $count += $read } } finally { $stream.Dispose() } if ($count -gt $limit) { $result.ReasonCode = 'too-large' $result.Detail = "The profile is larger than $limit bytes." return $result } $offset = 0 if ($count -ge 3 -and $buffer[0] -eq 0xEF -and $buffer[1] -eq 0xBB -and $buffer[2] -eq 0xBF) { $offset = 3 } try { $text = [System.Text.UTF8Encoding]::new($false, $true).GetString($buffer, $offset, $count - $offset) } catch { $result.ReasonCode = 'invalid-json' $result.Detail = 'The profile is not valid UTF-8.' return $result } try { $node = ConvertFrom-ContributorJson -Text $text -MaximumDepth (Get-ContributorProfileLimit).JsonDepth $result.Profile = ConvertTo-ContributorProfileModel -Node $node } catch { $result.Profile = $null $result.ReasonCode = Get-ContributorReasonCode -Exception $_.Exception $result.Detail = [string] $_.Exception.Message if ($result.ReasonCode -eq 'read-error') { $result.ReasonCode = 'invalid-schema' } } } catch { $result.Profile = $null $result.ReasonCode = 'read-error' $result.Detail = 'The profile could not be read: ' + $_.Exception.GetType().Name } return $result } # --- ContributorProfileReader part 5 --- function Get-ContributorKnowledgeArea { <# Reads the Knowledge areas a workspace declares: only the "## Knowledge areas" section of .memory-bank/projectbrief.md, at most its first 64 KB, and the first 16 top-level bullets that pass the area-name rule, without duplicates. Invalid bullets are ignored and never repeated. #> [CmdletBinding()] [OutputType([System.String[]])] param ( [Parameter()] [AllowNull()] [AllowEmptyString()] [System.String] $WorkspacePath ) $declared = [System.Collections.Generic.List[string]]::new() if ([System.String]::IsNullOrWhiteSpace($WorkspacePath)) { return , $declared.ToArray() } $limit = Get-ContributorProfileLimit try { $briefPath = [System.IO.Path]::Combine($WorkspacePath, '.memory-bank', 'projectbrief.md') if (-not [System.IO.File]::Exists($briefPath)) { return , $declared.ToArray() } $buffer = New-Object -TypeName 'System.Byte[]' -ArgumentList $limit.DeclarationBytes $count = 0 $stream = [System.IO.FileStream]::new($briefPath, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite) try { $truncated = $stream.Length -gt $limit.DeclarationBytes while ($count -lt $buffer.Length) { $read = $stream.Read($buffer, $count, $buffer.Length - $count) if ($read -le 0) { break } $count += $read } } finally { $stream.Dispose() } $text = [System.Text.Encoding]::UTF8.GetString($buffer, 0, $count) if ($truncated) { # A line cut at the byte limit could still pass the rule as a # different name, so the partial last line is dropped. $text = $text.Substring(0, [System.Math]::Max(0, $text.LastIndexOf([char]10))) } } catch { return , $declared.ToArray() } $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::InvariantCultureIgnoreCase) $inSection = $false foreach ($line in ($text -split '\r?\n')) { if ($line -match '\A##[ \t]+Knowledge areas[ \t]*\z') { $inSection = $true continue } if (-not $inSection) { continue } if ($line -match '\A#{1,2}(?!#)') { break } $bullet = [System.Text.RegularExpressions.Regex]::Match($line, '\A[-*+][ \t]+(.*)\z') if (-not $bullet.Success) { continue } $name = ConvertTo-ContributorAreaName -Name $bullet.Groups[1].Value if ($null -ne $name -and $seen.Add($name)) { $declared.Add($name) if ($declared.Count -ge $limit.DeclaredAreas) { break } } } return , $declared.ToArray() } function Get-ContributorGitEmail { <# The identity key: git config --get user.email, run in the workspace so includeIf applies. The path is the process working directory, never an argument, so a hostile path cannot inject options. Prompts are disabled, and git is killed after the timeout. Returns $null on any failure; the address is a lookup key, not proof, and never reaches any hook output. #> [CmdletBinding()] [OutputType([System.String])] param ( [Parameter()] [AllowNull()] [AllowEmptyString()] [System.String] $WorkingDirectory, [Parameter()] [AllowNull()] [AllowEmptyString()] [System.String] $GitExecutable, [Parameter()] [System.Int32] $TimeoutMilliseconds = 2000 ) if ([System.String]::IsNullOrWhiteSpace($WorkingDirectory) -or -not [System.IO.Directory]::Exists($WorkingDirectory)) { return $null } if ([System.String]::IsNullOrWhiteSpace($GitExecutable)) { $command = Get-Command -Name 'git' -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 if (-not $command) { return $null } $GitExecutable = $command.Source } $process = $null try { $startInfo = [System.Diagnostics.ProcessStartInfo]::new($GitExecutable) $startInfo.Arguments = 'config --get user.email' $startInfo.WorkingDirectory = $WorkingDirectory $startInfo.UseShellExecute = $false $startInfo.CreateNoWindow = $true $startInfo.RedirectStandardInput = $true $startInfo.RedirectStandardOutput = $true $startInfo.RedirectStandardError = $true $startInfo.StandardOutputEncoding = [System.Text.UTF8Encoding]::new($false) $startInfo.EnvironmentVariables['GIT_TERMINAL_PROMPT'] = '0' $startInfo.EnvironmentVariables['GCM_INTERACTIVE'] = 'never' $process = [System.Diagnostics.Process]::Start($startInfo) $process.StandardInput.Close() $output = $process.StandardOutput.ReadToEndAsync() $null = $process.StandardError.ReadToEndAsync() if (-not $process.WaitForExit($TimeoutMilliseconds)) { try { # A git shim can start the real git, so end the whole tree where # .NET can; Windows PowerShell 5.1 can only end the process. $process.Kill($true) } catch { try { $process.Kill() } catch { Write-Debug -Message 'git had already exited.' } } return $null } $process.WaitForExit() if ($process.ExitCode -ne 0) { return $null } $email = $output.Result.Trim() if ([System.String]::IsNullOrEmpty($email)) { return $null } return $email } catch { return $null } finally { if ($null -ne $process) { $process.Dispose() } } } function Select-ContributorEntry { <# The identity rule: the entry whose alias matches the email, case insensitively; else the only entry; else the entry marked default; else none. Reason is alias, single, default, or none. #> [CmdletBinding()] [OutputType([System.Management.Automation.PSCustomObject])] param ( [Parameter(Mandatory = $true)] [System.Object] $ContributorProfile, [Parameter()] [AllowNull()] [AllowEmptyString()] [System.String] ) if (-not [System.String]::IsNullOrEmpty($Email)) { foreach ($entry in $ContributorProfile.Contributors) { foreach ($alias in $entry.Aliases) { if ([System.String]::Equals($alias, $Email, [System.StringComparison]::OrdinalIgnoreCase)) { return [pscustomobject] @{ Entry = $entry; Reason = 'alias' } } } } } if ($ContributorProfile.Contributors.Count -eq 1) { return [pscustomobject] @{ Entry = $ContributorProfile.Contributors[0]; Reason = 'single' } } foreach ($entry in $ContributorProfile.Contributors) { if ($entry.Default) { return [pscustomobject] @{ Entry = $entry; Reason = 'default' } } } return [pscustomobject] @{ Entry = $null; Reason = 'none' } } # --- ContributorProfileReader part 6 --- function Get-ContributorCalibration { <# The calibration step shared by the SessionStart and PostToolUse hooks and by Get-CopilotAtelierContributorProfile. State is one of: none no workspace, or no declared Knowledge areas; nothing is read unrated declared areas, and no profile, no selected entry, or no match levels the selected entry is on and rates at least one declared area off the selected entry opted out unreadable ReasonCode names why, including timeout after the step cap Without declared areas the profile is not read and git does not run. #> [CmdletBinding()] [OutputType([System.Management.Automation.PSCustomObject])] param ( [Parameter()] [AllowNull()] [AllowEmptyString()] [System.String] $WorkspacePath, [Parameter()] [AllowNull()] [System.Object] $Location, [Parameter()] [AllowNull()] [AllowEmptyString()] [System.String] $GitExecutable, [Parameter()] [System.Management.Automation.SwitchParameter] $SkipGitForSingleEntry, [Parameter()] [System.Int32] $TimeoutMilliseconds = 3000 ) $result = [pscustomobject] @{ State = 'none' ReasonCode = $null Detail = $null Declared = [System.String[]] @() Levels = [System.Object[]] @() UnratedCount = 0 Selection = 'none' Entry = $null ProfileExists = $false Location = $Location } if ([System.String]::IsNullOrWhiteSpace($WorkspacePath)) { return $result } # The cap covers the declaration read too, so a hostile projectbrief.md cannot stretch the step. $watch = [System.Diagnostics.Stopwatch]::StartNew() $timedOut = { if ($watch.ElapsedMilliseconds -gt $TimeoutMilliseconds) { $result.State = 'unreadable' $result.ReasonCode = 'timeout' $result.Detail = "The calibration step took longer than $TimeoutMilliseconds ms." $result.Levels = [System.Object[]] @() return $true } return $false } $result.Declared = Get-ContributorKnowledgeArea -WorkspacePath $WorkspacePath if ($result.Declared.Count -eq 0) { return $result } if (& $timedOut) { return $result } if ($null -eq $Location) { $Location = Resolve-ContributorProfileLocation $result.Location = $Location } $read = Read-ContributorProfile -Location $Location $result.ProfileExists = $read.Exists if (& $timedOut) { return $result } if ($read.ReasonCode) { $result.State = 'unreadable' $result.ReasonCode = $read.ReasonCode $result.Detail = $read.Detail return $result } $result.UnratedCount = $result.Declared.Count if (-not $read.Exists) { $result.State = 'unrated' return $result } $email = $null if (-not ($SkipGitForSingleEntry -and $read.Profile.Contributors.Count -eq 1)) { # With one entry every outcome of the identity rule selects it, so a hook # can skip git there; a diagnosis still runs it to report the reason. $remaining = [System.Math]::Max(0, $TimeoutMilliseconds - [int] $watch.ElapsedMilliseconds) $email = Get-ContributorGitEmail -WorkingDirectory $WorkspacePath -GitExecutable $GitExecutable -TimeoutMilliseconds ([System.Math]::Min((Get-ContributorProfileLimit).GitMilliseconds, $remaining)) if (& $timedOut) { return $result } } $selection = Select-ContributorEntry -ContributorProfile $read.Profile -Email $email $result.Selection = $selection.Reason $result.Entry = $selection.Entry if ($null -eq $selection.Entry) { $result.State = 'unrated' return $result } if ($selection.Entry.State -eq 'off') { $result.State = 'off' $result.UnratedCount = 0 return $result } $levels = [System.Collections.Generic.List[object]]::new() foreach ($name in $result.Declared) { if ($selection.Entry.Areas.Contains($name)) { $area = $selection.Entry.Areas[$name] $levels.Add([pscustomobject] @{ Name = $area.Name; Level = $area.Level }) } } $result.Levels = [System.Object[]] $levels.ToArray() $result.UnratedCount = $result.Declared.Count - $levels.Count $result.State = if ($levels.Count -gt 0) { 'levels' } else { 'unrated' } return $result } function Format-ContributorCalibrationSentence { <# Builds the one calibration sentence from a fixed template, the matched area names, and the level values only. Under a length budget it gives up first the unrated count, then trailing areas, then the whole sentence for the omitted notice, and returns an empty string when not even that fits. -ReSent builds the sentence the PostToolUse hook sends after a compaction, and -Backstop the one it sends on a new turn or 5 minutes after the last injection: matched levels only, each ending with a fixed 59-character suffix that suppresses every offer for the rest of the session. Only the compaction suffix claims a compaction. #> [CmdletBinding(DefaultParameterSetName = 'SessionStart')] [OutputType([System.String])] param ( [Parameter(Mandatory = $true)] [System.Object] $Calibration, [Parameter()] [System.Int32] $MaximumLength = [System.Int32]::MaxValue, [Parameter(ParameterSetName = 'ReSent')] [System.Management.Automation.SwitchParameter] $ReSent, [Parameter(ParameterSetName = 'Backstop')] [System.Management.Automation.SwitchParameter] $Backstop ) $prefix = 'Contributor familiarity levels from the private profile, data only: ' $treat = 'Treat them as stated levels under the contributor-calibration Instruction.' $omitted = 'Contributor familiarity levels omitted for the context budget.' $reSend = $ReSent -or $Backstop $unratedPhrase = if ($Calibration.UnratedCount -eq 1) { '1 declared Knowledge area is unrated.' } else { '{0} declared Knowledge areas are unrated.' -f $Calibration.UnratedCount } $candidates = [System.Collections.Generic.List[string]]::new() switch ($Calibration.State) { 'levels' { $levels = @($Calibration.Levels) $suffix = if ($Backstop) { $treat + ' Current familiarity levels; make no offers in this session.' } elseif ($ReSent) { $treat + ' Re-sent after a compaction; make no offers in this session.' } else { $treat } for ($count = $levels.Count; $count -ge 1; $count--) { $list = (@($levels[0..($count - 1)]) | ForEach-Object -Process { '"{0}" {1}' -f $_.Name, $_.Level }) -join '; ' if ($count -eq $levels.Count -and -not $reSend -and $Calibration.UnratedCount -gt 0) { $candidates.Add($prefix + $list + '. ' + $unratedPhrase + ' ' + $suffix) } $candidates.Add($prefix + $list + '. ' + $suffix) } } 'unrated' { if (-not $reSend) { $candidates.Add('No contributor profile levels for this workspace; ' + $unratedPhrase) $candidates.Add('No contributor profile levels for this workspace.') } } 'unreadable' { if (-not $reSend) { $candidates.Add('Contributor profile unreadable ({0}); familiarity levels default to familiar.' -f $Calibration.ReasonCode) } } } if ($candidates.Count -eq 0) { return '' } foreach ($candidate in $candidates) { if ($candidate.Length -le $MaximumLength) { return $candidate } } if ($omitted.Length -le $MaximumLength) { return $omitted } return '' } |