@danmoisan/drm-copilot-mcp 1.1.11 → 1.1.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/out/mcp-server.js +368 -209
  2. package/package.json +2 -2
  3. package/resources/claude-customizations/.claude/agents/epic-orchestrator.md +1 -1
  4. package/resources/claude-customizations/.claude/agents/epic-planner.md +4 -1
  5. package/resources/claude-customizations/.claude/agents/parallel-planner.md +18 -1
  6. package/resources/claude-customizations/.claude/agents/pr-author.md +15 -0
  7. package/resources/claude-customizations/.claude/hooks/enforce-completion-consistency.ps1 +16 -8
  8. package/resources/claude-customizations/.claude/hooks/enforce-epic-merge-gate-authorization.ps1 +443 -0
  9. package/resources/claude-customizations/.claude/hooks/enforce-epic-merge-gate.ps1 +33 -37
  10. package/resources/claude-customizations/.claude/hooks/enforce-model-routing-receipt.ps1 +120 -5
  11. package/resources/claude-customizations/.claude/hooks/enforce-orchestration-preimplementation-gate-epic-scope.ps1 +127 -0
  12. package/resources/claude-customizations/.claude/hooks/enforce-orchestration-preimplementation-gate-helpers.ps1 +172 -24
  13. package/resources/claude-customizations/.claude/hooks/enforce-orchestration-preimplementation-gate.ps1 +14 -27
  14. package/resources/claude-customizations/.claude/hooks/enforce-parallel-worktree-removal-gate.ps1 +66 -5
  15. package/resources/claude-customizations/.claude/hooks/enforce-pr-author-skill-helpers.ps1 +134 -10
  16. package/resources/claude-customizations/.claude/hooks/enforce-pr-author-skill.epic-base-branch.ps1 +37 -8
  17. package/resources/claude-customizations/.claude/hooks/enforce-pr-author-skill.ps1 +3 -1
  18. package/resources/claude-customizations/.claude/hooks/enforce-prd-feature-before-planner-helpers.ps1 +332 -0
  19. package/resources/claude-customizations/.claude/hooks/enforce-prd-feature-before-planner.ps1 +211 -182
  20. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadius.psm1 +43 -6
  21. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusScheduling.psm1 +486 -0
  22. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusValidation.psm1 +5 -2
  23. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusWriteIntent.psm1 +446 -0
  24. package/resources/claude-customizations/.claude/lib/codex-routing/CodexDeployment.psm1 +1 -0
  25. package/resources/claude-customizations/.claude/lib/worktree-resolution/EpicScopeReadiness.psm1 +176 -0
  26. package/resources/claude-customizations/.claude/lib/worktree-resolution/EpicScopeResolution.psm1 +369 -0
  27. package/resources/claude-customizations/.claude/lib/worktree-resolution/WorktreeItemResolution.psm1 +392 -0
  28. package/resources/claude-customizations/.claude/lib/worktree-resolution/WorktreeResolution.psm1 +500 -0
  29. package/resources/claude-customizations/.claude/lib/worktree-resolution/WorktreeTargetResolution.psm1 +347 -0
  30. package/resources/claude-customizations/.claude/rules/orchestrator-state.md +27 -0
  31. package/resources/claude-customizations/.claude/rules/parallel-orchestration.md +150 -2
  32. package/resources/claude-customizations/.claude/rules/plan-acceptance-gates.md +5 -5
  33. package/resources/claude-customizations/.claude/skills/cleanup-merged-worktrees/SKILL.md +12 -0
  34. package/resources/claude-customizations/.claude/skills/epic-orchestrate/SKILL.md +14 -1
  35. package/resources/claude-customizations/.claude/skills/epic-plan/SKILL.md +15 -0
  36. package/resources/claude-customizations/.claude/skills/orchestrate/SKILL.md +4 -1
  37. package/resources/claude-customizations/.claude/skills/parallel-add/SKILL.md +32 -17
  38. package/resources/claude-customizations/.claude/skills/parallel-orchestrate/SKILL.md +39 -6
  39. package/resources/claude-customizations/.claude/skills/parallel-plan/SKILL.md +39 -10
  40. package/resources/claude-customizations/.claude/skills/pr-author/SKILL.md +15 -0
  41. package/resources/claude-customizations/.claude/skills/quota-throttling/SKILL.md +221 -0
  42. package/resources/claude-customizations/config/blast-radius.json +14 -1
  43. package/resources/claude-customizations/pack-manifests/core.json +11 -0
  44. package/resources/codex-and-agents-customizations/.agents/skills/codex-model-routing/SKILL.md +13 -14
  45. package/resources/codex-and-agents-customizations/.codex/config.toml +1 -1
  46. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-epic-merge-gate.ps1 +193 -0
  47. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-epic-planning-only.ps1 +15 -5
  48. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-orchestration-preimplementation-gate-epic-resolution.ps1 +493 -0
  49. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-orchestration-preimplementation-gate-epic-scope.ps1 +189 -0
  50. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-orchestration-preimplementation-gate-helpers.ps1 +172 -24
  51. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-orchestration-preimplementation-gate.ps1 +14 -27
  52. package/resources/codex-and-agents-customizations/.codex/scripts/Resolve-CodexDeployment.ps1 +87 -0
  53. package/resources/codex-and-agents-customizations/.codex/scripts/Resolve-CodexTopology.ps1 +96 -0
  54. package/resources/codex-and-agents-customizations/.codex/scripts/codex-routing-cli-common.ps1 +370 -0
  55. package/resources/codex-and-agents-customizations/.codex/scripts/epic-child-launch-contract.ps1 +475 -0
  56. package/resources/codex-and-agents-customizations/.codex/scripts/epic-child-launch-runtime.ps1 +445 -0
  57. package/resources/codex-and-agents-customizations/.codex/scripts/epic-child-persistence-runtime.ps1 +118 -0
  58. package/resources/codex-and-agents-customizations/.codex/scripts/epic-child-sandbox-preflight.ps1 +69 -0
  59. package/resources/codex-and-agents-customizations/.codex/scripts/launch-epic-child-wave.ps1 +471 -0
  60. package/resources/codex-and-agents-customizations/.codex/scripts/post-codex-worktree-session.ps1 +280 -0
  61. package/resources/codex-and-agents-customizations/.codex/scripts/resume-epic-child.ps1 +263 -0
  62. package/resources/codex-and-agents-customizations/.codex-variants/csharp-legacy/agents/csharp-typed-engineer.toml +2 -1
  63. package/resources/codex-and-agents-customizations/pack-manifests/core.json +20 -1
  64. package/resources/lib/codex-routing/CodexDeployment.psm1 +315 -0
  65. package/resources/lib/codex-routing/CodexTopology.psm1 +394 -0
  66. package/resources/powershell/PoshQC/settings/pester.runsettings.psd1 +37 -0
@@ -0,0 +1,500 @@
1
+ <#
2
+ .SYNOPSIS
3
+ Worktree locator and path normaliser for target-worktree resolution (issue #669).
4
+
5
+ .DESCRIPTION
6
+ Locates the worktree that contains a path by an upward walk for a .git entry,
7
+ enumerates the worktrees reachable from a session root through the repository
8
+ administrative directory, and converts paths between absolute and repo-relative
9
+ form without truncating them. It also publishes the single ambiguity reason
10
+ code that downstream gates concatenate into a deny reason.
11
+
12
+ A level is a worktree root when its .git child is a directory (the main
13
+ checkout) or a file whose first line is a gitdir: line (a linked worktree). A
14
+ linked worktree may live outside the main checkout, so containment is never
15
+ decided by comparing a path against one known root.
16
+
17
+ .NOTES
18
+ Compatible with PowerShell 7+. No external module dependencies, no subprocess,
19
+ no network, no clock read, and no environment read. All filesystem contact
20
+ passes through three exported seams so tests model any worktree topology
21
+ without writing a temporary file. Mirrored byte-identically under
22
+ extensions/drm-copilot/resources/claude-customizations/.
23
+ CONVENTION: this module fails fast at module scope and imports its siblings with -ErrorAction Stop.
24
+ #>
25
+
26
+ Set-StrictMode -Version Latest
27
+ $ErrorActionPreference = 'Stop'
28
+
29
+ # The name of the entry that marks a worktree root. Only this one name is probed,
30
+ # because the repository layout never uses a separate administrative directory.
31
+ $script:GitEntryName = '.git'
32
+
33
+ # The first-line prefix of a linked worktree's .git file, and the pattern that
34
+ # captures its target. Anything else in a .git file is not a root marker.
35
+ $script:GitDirPrefix = 'gitdir:'
36
+ $script:GitDirLinePattern = '^' + [regex]::Escape($script:GitDirPrefix) + '\s*(.+)$'
37
+
38
+ # The per-worktree administrative files read during enumeration. commondir leads
39
+ # from a linked worktree's administrative directory back to the main .git
40
+ # directory; worktrees holds one administrative directory per linked worktree.
41
+ $script:CommonDirFileName = 'commondir'
42
+ $script:WorktreeAdminDirectoryName = 'worktrees'
43
+ $script:WorktreeGitDirFileName = 'gitdir'
44
+ $script:HeadFileName = 'HEAD'
45
+
46
+ # The only HEAD form that names a branch. A detached HEAD names no branch, so it
47
+ # never matches a branch filter.
48
+ $script:BranchReferencePrefix = 'ref: refs/heads/'
49
+
50
+ # Upper bound on the upward walk, so a malformed input cannot loop.
51
+ $script:MaximumAscentDepth = 64
52
+
53
+ # The single cause code emitted when the correct target cannot be identified. It
54
+ # carries no _BLOCKED suffix because this module owns no gate.
55
+ $script:AmbiguityReasonCode = 'TARGET_WORKTREE_AMBIGUOUS'
56
+ # The no-target reason code (issue #687). Without it a NoTarget result carried nothing a gate
57
+ # could emit, leaving only wrong options: substitute the session root, which in a parallel or
58
+ # epic topology is a sibling item's state, or deny without naming why.
59
+ $script:NoTargetReasonCode = 'TARGET_WORKTREE_NOT_DERIVABLE'
60
+
61
+ function Get-WorktreeResolutionGitEntryKind {
62
+ <#
63
+ .SYNOPSIS
64
+ Classify a path as Directory, File, or None. The module's only existence probe (seam).
65
+ .PARAMETER Path
66
+ The absolute path to classify.
67
+ #>
68
+ [CmdletBinding()]
69
+ [OutputType([string])]
70
+ param(
71
+ [Parameter(Mandatory = $true)]
72
+ [string] $Path
73
+ )
74
+
75
+ if (Test-Path -LiteralPath $Path -PathType Container) {
76
+ return 'Directory'
77
+ }
78
+ if (Test-Path -LiteralPath $Path -PathType Leaf) {
79
+ return 'File'
80
+ }
81
+ return 'None'
82
+ }
83
+
84
+ function Get-WorktreeResolutionGitFileText {
85
+ <#
86
+ .SYNOPSIS
87
+ Read a file's raw text, or $null when unreadable. The module's only content read (seam).
88
+ .PARAMETER Path
89
+ The absolute path of the file to read.
90
+ #>
91
+ [CmdletBinding()]
92
+ [OutputType([string])]
93
+ param(
94
+ [Parameter(Mandatory = $true)]
95
+ [string] $Path
96
+ )
97
+
98
+ return (Get-Content -LiteralPath $Path -Raw -ErrorAction SilentlyContinue)
99
+ }
100
+
101
+ function Get-WorktreeResolutionDirectoryChildName {
102
+ <#
103
+ .SYNOPSIS
104
+ List child directory names as an array (empty when absent). The module's only directory enumeration (seam).
105
+ .PARAMETER Path
106
+ The absolute path of the directory to list.
107
+ #>
108
+ [CmdletBinding()]
109
+ [OutputType([string[]], [object[]])]
110
+ param(
111
+ [Parameter(Mandatory = $true)]
112
+ [string] $Path
113
+ )
114
+
115
+ return , [string[]] @(Get-ChildItem -LiteralPath $Path -Directory -Name -ErrorAction SilentlyContinue)
116
+ }
117
+
118
+ function ConvertTo-WorktreeResolutionNormalizedPath {
119
+ <#
120
+ .SYNOPSIS
121
+ Normalise a path to forward slashes, no repeated separator, no trailing slash, no leading ./.
122
+ .DESCRIPTION
123
+ Pure. Returns $null for a null, empty, or whitespace input. A UNC share prefix
124
+ and the filesystem root / are preserved.
125
+ .PARAMETER Path
126
+ The path to normalise, in either separator style.
127
+ #>
128
+ [CmdletBinding()]
129
+ [OutputType([string])]
130
+ param(
131
+ [Parameter(Mandatory = $true)]
132
+ [AllowNull()]
133
+ [AllowEmptyString()]
134
+ [string] $Path
135
+ )
136
+
137
+ if ([string]::IsNullOrWhiteSpace($Path)) {
138
+ return $null
139
+ }
140
+ $normalized = $Path.Trim() -replace '\\', '/'
141
+ $uncPrefix = if ($normalized.StartsWith('//')) { '/' } else { '' }
142
+ $normalized = $uncPrefix + ($normalized -replace '/{2,}', '/')
143
+ while ($normalized.StartsWith('./')) {
144
+ $normalized = $normalized.Substring(2)
145
+ }
146
+ if ($normalized.Length -gt 1) {
147
+ $normalized = $normalized.TrimEnd('/')
148
+ }
149
+ if ($normalized.Length -eq 0) {
150
+ return $null
151
+ }
152
+ return $normalized
153
+ }
154
+
155
+ function Test-WorktreeResolutionAbsolutePath {
156
+ # Pure: true for a drive-rooted (C:/...) or slash-rooted normalised path.
157
+ [OutputType([bool])]
158
+ param([string] $Path)
159
+
160
+ return ($Path -match '^([A-Za-z]:(/|$)|/)')
161
+ }
162
+
163
+ function Join-WorktreeResolutionSegment {
164
+ # Pure: append a child to a normalised base without doubling the separator.
165
+ [OutputType([string])]
166
+ param([string] $BasePath, [string] $ChildPath)
167
+
168
+ $separator = if ($BasePath.EndsWith('/')) { '' } else { '/' }
169
+ return $BasePath + $separator + $ChildPath
170
+ }
171
+
172
+ function Get-WorktreeResolutionParentPath {
173
+ # Pure: the parent of a normalised absolute path, or $null at the filesystem or
174
+ # drive root. The parent of C:/a is C:, C: has no parent, and the parent of /a is /.
175
+ [OutputType([string])]
176
+ param([string] $Path)
177
+
178
+ $index = $Path.LastIndexOf('/')
179
+ if ($index -lt 0 -or $Path -eq '/') {
180
+ return $null
181
+ }
182
+ return $Path.Substring(0, [math]::Max($index, 1))
183
+ }
184
+
185
+ function Resolve-WorktreeResolutionPathAgainst {
186
+ # Pure: resolve a path relative to a normalised absolute base, collapsing . and
187
+ # .. segments. An absolute path is normalised and collapsed without the base.
188
+ [OutputType([string])]
189
+ param([string] $BasePath, [string] $Path)
190
+
191
+ $normalized = ConvertTo-WorktreeResolutionNormalizedPath -Path $Path
192
+ if ($null -eq $normalized) {
193
+ return $null
194
+ }
195
+ if (-not (Test-WorktreeResolutionAbsolutePath -Path $normalized)) {
196
+ $normalized = Join-WorktreeResolutionSegment -BasePath $BasePath -ChildPath $normalized
197
+ }
198
+ $kept = [System.Collections.Generic.List[string]]::new()
199
+ foreach ($segment in $normalized.Split('/')) {
200
+ if ($segment -eq '..') {
201
+ if ($kept.Count -gt 1) {
202
+ $kept.RemoveAt($kept.Count - 1)
203
+ }
204
+ } elseif ($segment -ne '.') {
205
+ $kept.Add($segment)
206
+ }
207
+ }
208
+ $joined = $kept -join '/'
209
+ if ($joined.Length -eq 0) {
210
+ return '/'
211
+ }
212
+ return $joined
213
+ }
214
+
215
+ function Get-WorktreeResolutionGitDirTarget {
216
+ # Pure: the target named by the first line of .git file text when that line is a
217
+ # well-formed gitdir: line, otherwise $null.
218
+ [OutputType([string])]
219
+ param([AllowNull()][string] $Text)
220
+
221
+ $firstLine = Get-WorktreeResolutionFirstLine -Text $Text
222
+ if ($null -ne $firstLine -and $firstLine -match $script:GitDirLinePattern) {
223
+ return $Matches[1].Trim()
224
+ }
225
+ return $null
226
+ }
227
+
228
+ function Test-WorktreeResolutionRootMarker {
229
+ <#
230
+ .SYNOPSIS
231
+ Test whether a directory level is a worktree root.
232
+ .DESCRIPTION
233
+ True when the level's .git child is a directory, or a file whose first line is
234
+ a well-formed gitdir: line. Reaches the filesystem only through the seams.
235
+ .PARAMETER Path
236
+ The absolute directory level to test.
237
+ #>
238
+ [CmdletBinding()]
239
+ [OutputType([bool])]
240
+ param(
241
+ [Parameter(Mandatory = $true)]
242
+ [string] $Path
243
+ )
244
+
245
+ $level = ConvertTo-WorktreeResolutionNormalizedPath -Path $Path
246
+ if ($null -eq $level) {
247
+ return $false
248
+ }
249
+ $gitEntry = Join-WorktreeResolutionSegment -BasePath $level -ChildPath $script:GitEntryName
250
+ $kind = Get-WorktreeResolutionGitEntryKind -Path $gitEntry
251
+ if ($kind -ne 'File') {
252
+ return ($kind -eq 'Directory')
253
+ }
254
+ $target = Get-WorktreeResolutionGitDirTarget -Text (Get-WorktreeResolutionGitFileText -Path $gitEntry)
255
+ return ($null -ne $target)
256
+ }
257
+
258
+ function Find-WorktreeResolutionRoot {
259
+ <#
260
+ .SYNOPSIS
261
+ Ascend from a path to the first level that is a worktree root.
262
+ .DESCRIPTION
263
+ Returns the normalised root, or $null when the input is not absolute (it would
264
+ depend on the current directory), when the ascent reaches the filesystem or
265
+ drive root without a marker, or when MaximumDepth levels have been tested.
266
+ .PARAMETER Path
267
+ The absolute path to start from; the path itself is level 0.
268
+ .PARAMETER MaximumDepth
269
+ The highest level above the input that is tested.
270
+ #>
271
+ [CmdletBinding()]
272
+ [OutputType([string])]
273
+ param(
274
+ [Parameter(Mandatory = $true)]
275
+ [AllowEmptyString()]
276
+ [string] $Path,
277
+
278
+ [ValidateRange(0, 4096)]
279
+ [int] $MaximumDepth = $script:MaximumAscentDepth
280
+ )
281
+
282
+ $current = ConvertTo-WorktreeResolutionNormalizedPath -Path $Path
283
+ if ($null -eq $current -or -not (Test-WorktreeResolutionAbsolutePath -Path $current)) {
284
+ return $null
285
+ }
286
+ for ($depth = 0; ($null -ne $current) -and ($depth -le $MaximumDepth); $depth++) {
287
+ if (Test-WorktreeResolutionRootMarker -Path $current) {
288
+ return $current
289
+ }
290
+ $current = Get-WorktreeResolutionParentPath -Path $current
291
+ }
292
+ return $null
293
+ }
294
+
295
+ function Get-WorktreeResolutionFirstLine {
296
+ # Pure: the trimmed first line of a text, or $null when there is none.
297
+ [OutputType([string])]
298
+ param([AllowNull()][string] $Text)
299
+
300
+ if ([string]::IsNullOrWhiteSpace($Text)) {
301
+ return $null
302
+ }
303
+ return ($Text -split "`r?`n", 2)[0].Trim()
304
+ }
305
+
306
+ function Get-WorktreeResolutionCommonGitDirectory {
307
+ # The main .git directory reachable from a worktree root, or $null. A .git
308
+ # directory is the main checkout's own. A .git file's gitdir: target is the
309
+ # per-worktree admin directory, whose commondir file leads back to main .git.
310
+ [OutputType([string])]
311
+ param([string] $WorktreeRoot)
312
+
313
+ $gitEntry = Join-WorktreeResolutionSegment -BasePath $WorktreeRoot -ChildPath $script:GitEntryName
314
+ $kind = Get-WorktreeResolutionGitEntryKind -Path $gitEntry
315
+ if ($kind -eq 'Directory') {
316
+ return $gitEntry
317
+ }
318
+ if ($kind -ne 'File') {
319
+ return $null
320
+ }
321
+ $adminTarget = Get-WorktreeResolutionGitDirTarget -Text (Get-WorktreeResolutionGitFileText -Path $gitEntry)
322
+ if ($null -eq $adminTarget) {
323
+ return $null
324
+ }
325
+ $adminDirectory = Resolve-WorktreeResolutionPathAgainst -BasePath $WorktreeRoot -Path $adminTarget
326
+ $commonDirPath = Join-WorktreeResolutionSegment -BasePath $adminDirectory -ChildPath $script:CommonDirFileName
327
+ $commonTarget = Get-WorktreeResolutionFirstLine -Text (Get-WorktreeResolutionGitFileText -Path $commonDirPath)
328
+ if ($null -eq $commonTarget) {
329
+ return $null
330
+ }
331
+ return (Resolve-WorktreeResolutionPathAgainst -BasePath $adminDirectory -Path $commonTarget)
332
+ }
333
+
334
+ function Get-WorktreeResolutionWorktreeRoot {
335
+ <#
336
+ .SYNOPSIS
337
+ Enumerate the candidate worktree roots reachable from a session root.
338
+ .DESCRIPTION
339
+ Reads the administrative layout through the seams and starts no process. The
340
+ main checkout is always a candidate. Each worktrees/<name>/gitdir file names a
341
+ linked worktree's own .git file, whose parent directory is that worktree's
342
+ root and may lie outside the main checkout. Always returns an array.
343
+ .PARAMETER SessionRoot
344
+ The worktree root the invoking process runs in.
345
+ .PARAMETER Branch
346
+ Optional. Keep only candidates whose HEAD names this branch.
347
+ .PARAMETER RepoRelativePath
348
+ Optional. Keep only candidates under which this repo-relative path exists.
349
+ #>
350
+ [CmdletBinding()]
351
+ [OutputType([string[]], [object[]])]
352
+ param(
353
+ [Parameter(Mandatory = $true)]
354
+ [AllowEmptyString()]
355
+ [string] $SessionRoot,
356
+ [string] $Branch,
357
+ [string] $RepoRelativePath
358
+ )
359
+
360
+ $sessionLevel = ConvertTo-WorktreeResolutionNormalizedPath -Path $SessionRoot
361
+ $commonGitDirectory = if ($null -ne $sessionLevel) { Get-WorktreeResolutionCommonGitDirectory -WorktreeRoot $sessionLevel }
362
+ if ($null -eq $commonGitDirectory) {
363
+ return , [string[]] @()
364
+ }
365
+
366
+ # Deduplication below is case-insensitive, matching the Windows filesystem.
367
+ $entries = [System.Collections.Generic.List[hashtable]]::new()
368
+ if ($commonGitDirectory.EndsWith('/' + $script:GitEntryName)) {
369
+ $mainHead = Join-WorktreeResolutionSegment -BasePath $commonGitDirectory -ChildPath $script:HeadFileName
370
+ $entries.Add(@{ Root = (Get-WorktreeResolutionParentPath -Path $commonGitDirectory); Head = $mainHead })
371
+ }
372
+ $adminRoot = Join-WorktreeResolutionSegment -BasePath $commonGitDirectory -ChildPath $script:WorktreeAdminDirectoryName
373
+ # Assigned before enumeration: the seam emits its array as one object.
374
+ $adminNames = Get-WorktreeResolutionDirectoryChildName -Path $adminRoot
375
+ foreach ($name in @($adminNames)) {
376
+ $adminDirectory = Join-WorktreeResolutionSegment -BasePath $adminRoot -ChildPath $name
377
+ $gitDirFile = Join-WorktreeResolutionSegment -BasePath $adminDirectory -ChildPath $script:WorktreeGitDirFileName
378
+ $gitFileTarget = Get-WorktreeResolutionFirstLine -Text (Get-WorktreeResolutionGitFileText -Path $gitDirFile)
379
+ if ($null -eq $gitFileTarget) {
380
+ continue
381
+ }
382
+ $gitFilePath = Resolve-WorktreeResolutionPathAgainst -BasePath $adminDirectory -Path $gitFileTarget
383
+ $linkedHead = Join-WorktreeResolutionSegment -BasePath $adminDirectory -ChildPath $script:HeadFileName
384
+ $entries.Add(@{ Root = (Get-WorktreeResolutionParentPath -Path $gitFilePath); Head = $linkedHead })
385
+ }
386
+
387
+ $relative = ConvertTo-WorktreeResolutionNormalizedPath -Path $RepoRelativePath
388
+ $wantedHead = if ([string]::IsNullOrWhiteSpace($Branch)) { $null } else { $script:BranchReferencePrefix + $Branch.Trim() }
389
+ $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
390
+ $roots = [System.Collections.Generic.List[string]]::new()
391
+ foreach ($entry in $entries) {
392
+ if ($null -eq $entry.Root -or -not $seen.Add($entry.Root)) {
393
+ continue
394
+ }
395
+ if ($null -ne $wantedHead -and (Get-WorktreeResolutionFirstLine -Text (Get-WorktreeResolutionGitFileText -Path $entry.Head)) -cne $wantedHead) {
396
+ continue
397
+ }
398
+ if ($null -ne $relative -and (Get-WorktreeResolutionGitEntryKind -Path (Join-WorktreeResolutionSegment -BasePath $entry.Root -ChildPath $relative)) -eq 'None') {
399
+ continue
400
+ }
401
+ $roots.Add($entry.Root)
402
+ }
403
+ return , [string[]] $roots.ToArray()
404
+ }
405
+
406
+ function ConvertTo-WorktreeResolutionNormalizationResult {
407
+ # Pure: the single constructor of the normalisation result. ReasonCode is the
408
+ # ambiguity literal exactly when the path was not normalised.
409
+ [OutputType([pscustomobject])]
410
+ param([string] $RepoRelativePath, [string] $WorktreeRoot, [bool] $IsNormalized, [string] $Detail)
411
+
412
+ return [pscustomobject]@{
413
+ IsNormalized = $IsNormalized
414
+ RepoRelativePath = if ($IsNormalized) { $RepoRelativePath } else { $null }
415
+ WorktreeRoot = if ($IsNormalized) { $WorktreeRoot } else { $null }
416
+ ReasonCode = if ($IsNormalized) { $null } else { $script:AmbiguityReasonCode }
417
+ Detail = $Detail
418
+ }
419
+ }
420
+
421
+ function ConvertTo-WorktreeResolutionRepoRelativePath {
422
+ <#
423
+ .SYNOPSIS
424
+ Convert a path to its repo-relative form by locating its containing worktree.
425
+ .DESCRIPTION
426
+ Always returns an object. An absolute path is placed by the upward walk and
427
+ keeps its full remainder below the located root; no segment is dropped. A
428
+ relative path is normalised only against an explicit WorktreeRoot, and is
429
+ otherwise refused (Ruling A) because it would depend on the current directory.
430
+ .PARAMETER Path
431
+ The path to convert, relative or absolute, in either separator style.
432
+ .PARAMETER WorktreeRoot
433
+ Optional. The worktree root a relative path is normalised against.
434
+ #>
435
+ [CmdletBinding()]
436
+ [OutputType([pscustomobject])]
437
+ param(
438
+ [Parameter(Mandatory = $true)]
439
+ [AllowEmptyString()]
440
+ [string] $Path,
441
+ [string] $WorktreeRoot
442
+ )
443
+
444
+ $normalized = ConvertTo-WorktreeResolutionNormalizedPath -Path $Path
445
+ if ($null -eq $normalized) {
446
+ return (ConvertTo-WorktreeResolutionNormalizationResult -IsNormalized $false -Detail 'the path to normalise is empty, so no containing worktree can be located')
447
+ }
448
+ if (Test-WorktreeResolutionAbsolutePath -Path $normalized) {
449
+ $root = Find-WorktreeResolutionRoot -Path $normalized
450
+ if ($null -eq $root) {
451
+ return (ConvertTo-WorktreeResolutionNormalizationResult -IsNormalized $false -Detail "no worktree root was found on the upward walk from '$normalized'")
452
+ }
453
+ $remainder = $normalized.Substring($root.Length).TrimStart('/')
454
+ return (ConvertTo-WorktreeResolutionNormalizationResult -IsNormalized $true -WorktreeRoot $root -RepoRelativePath $remainder -Detail "path '$normalized' lies in worktree '$root'")
455
+ }
456
+ $suppliedRoot = ConvertTo-WorktreeResolutionNormalizedPath -Path $WorktreeRoot
457
+ if ($null -eq $suppliedRoot) {
458
+ return (ConvertTo-WorktreeResolutionNormalizationResult -IsNormalized $false -Detail "relative path '$normalized' was supplied without a worktree root, so its containing worktree cannot be identified")
459
+ }
460
+ return (ConvertTo-WorktreeResolutionNormalizationResult -IsNormalized $true -WorktreeRoot $suppliedRoot -RepoRelativePath $normalized -Detail "relative path '$normalized' was normalised against the supplied worktree '$suppliedRoot'")
461
+ }
462
+
463
+ function Get-WorktreeResolutionAmbiguityReasonCode {
464
+ <#
465
+ .SYNOPSIS
466
+ Return the ambiguity reason code, TARGET_WORKTREE_AMBIGUOUS, so callers never restate it.
467
+ #>
468
+ [CmdletBinding()]
469
+ [OutputType([string])]
470
+ param()
471
+
472
+ return $script:AmbiguityReasonCode
473
+ }
474
+
475
+ function Get-WorktreeResolutionNoTargetReasonCode {
476
+ <#
477
+ .SYNOPSIS
478
+ Return the no-target reason code, TARGET_WORKTREE_NOT_DERIVABLE, so callers never restate it.
479
+ .DESCRIPTION
480
+ Distinct from the ambiguity code: that means signals disagreed, this means the
481
+ payload carried no signal at all, and a gate reports a different remedy for each.
482
+ #>
483
+ [CmdletBinding()]
484
+ [OutputType([string])]
485
+ param()
486
+
487
+ return $script:NoTargetReasonCode
488
+ }
489
+
490
+ Export-ModuleMember -Function `
491
+ Get-WorktreeResolutionGitEntryKind, `
492
+ Get-WorktreeResolutionGitFileText, `
493
+ Get-WorktreeResolutionDirectoryChildName, `
494
+ ConvertTo-WorktreeResolutionNormalizedPath, `
495
+ Test-WorktreeResolutionRootMarker, `
496
+ Find-WorktreeResolutionRoot, `
497
+ Get-WorktreeResolutionWorktreeRoot, `
498
+ ConvertTo-WorktreeResolutionRepoRelativePath, `
499
+ Get-WorktreeResolutionAmbiguityReasonCode, `
500
+ Get-WorktreeResolutionNoTargetReasonCode