@danmoisan/drm-copilot-mcp 1.0.21 → 1.0.22

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 (31) hide show
  1. package/out/mcp-server.js +1624 -190
  2. package/package.json +1 -1
  3. package/resources/claude-customizations/.claude/agent-memory/epic-orchestrator/MEMORY.md +5 -1
  4. package/resources/claude-customizations/.claude/agent-memory/epic-orchestrator/feedback_commit_push_memory_before_pr.md +48 -2
  5. package/resources/claude-customizations/.claude/agent-memory/epic-orchestrator/feedback_no_sendmessage_tool.md +35 -0
  6. package/resources/claude-customizations/.claude/agent-memory/epic-orchestrator/feedback_worktree_isolation_branches_from_main.md +45 -0
  7. package/resources/claude-customizations/.claude/agents/parallel-orchestrator.md +238 -0
  8. package/resources/claude-customizations/.claude/agents/parallel-planner.md +149 -0
  9. package/resources/claude-customizations/.claude/hooks/enforce-epic-invocation-origin.ps1 +23 -11
  10. package/resources/claude-customizations/.claude/hooks/enforce-parallel-abandon-gate.ps1 +259 -0
  11. package/resources/claude-customizations/.claude/hooks/enforce-parallel-cohort-barrier.ps1 +499 -0
  12. package/resources/claude-customizations/.claude/hooks/enforce-parallel-drift-gate-helpers.ps1 +302 -0
  13. package/resources/claude-customizations/.claude/hooks/enforce-parallel-drift-gate.ps1 +359 -0
  14. package/resources/claude-customizations/.claude/hooks/enforce-parallel-worktree-removal-gate.ps1 +244 -0
  15. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadius.psm1 +379 -0
  16. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusConfig.psm1 +491 -0
  17. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusExtraction.psm1 +490 -0
  18. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusGlob.psm1 +429 -0
  19. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusValidation.psm1 +366 -0
  20. package/resources/claude-customizations/.claude/rules/parallel-orchestration.md +184 -0
  21. package/resources/claude-customizations/.claude/settings.json +25 -0
  22. package/resources/claude-customizations/.claude/skills/parallel-add/SKILL.md +148 -0
  23. package/resources/claude-customizations/.claude/skills/parallel-close/SKILL.md +93 -0
  24. package/resources/claude-customizations/.claude/skills/parallel-orchestrate/SKILL.md +960 -0
  25. package/resources/claude-customizations/.claude/skills/parallel-plan/SKILL.md +420 -0
  26. package/resources/claude-customizations/.claude/skills/parallel-remove/SKILL.md +176 -0
  27. package/resources/claude-customizations/.claude/skills/parallel-run/SKILL.md +56 -0
  28. package/resources/claude-customizations/pack-manifests/core.json +19 -1
  29. package/resources/codex-and-agents-customizations/.codex/config.toml +1 -1
  30. package/resources/config/orchestration-routing.json +22 -0
  31. package/resources/powershell/PoshQC/settings/pester.runsettings.psd1 +29 -0
@@ -0,0 +1,244 @@
1
+ <#
2
+ .SYNOPSIS
3
+ Pre-tool-use hook that gates git worktree remove behind parallel checkpoint merge state.
4
+
5
+ .DESCRIPTION
6
+ Invoked by the Claude Code PreToolUse hook on the "Bash" matcher before any Bash
7
+ command runs. Regex-matches git worktree remove against CLAUDE_TOOL_INPUT.command,
8
+ extracts the target worktree path argument, reads
9
+ artifacts/orchestration/parallel-orchestrator-state.json, and finds the items[] record
10
+ whose worktree_path matches. Allows removal only when that record's merge_status is
11
+ merged or worktree_removed. Denies with reason PARALLEL_WORKTREE_REMOVAL_BLOCKED when
12
+ the checkpoint is unreadable, no matching record exists, or merge_status is anything
13
+ else - fail-closed, following the enforce-epic-worktree-removal-gate.ps1 precedent of
14
+ treating an unreadable/no-match checkpoint as deny.
15
+
16
+ Adapted from enforce-epic-worktree-removal-gate.ps1. The command interception regexes
17
+ and the path normalization are unchanged; the checkpoint path, the read seam name, and
18
+ the record collection differ, because the parallel surface records per-item state in
19
+ items[] rather than features[]. A parallel run has no integration branch: each item
20
+ opens its own pull request against main, so a removed worktree is unrecoverable work
21
+ unless that item's own merge has been durably confirmed.
22
+
23
+ .NOTES
24
+ Compatible with PowerShell 7+. No external module dependencies. Filesystem reads go
25
+ through an injectable wrapper function so tests can mock the boundary without writing
26
+ temporary files.
27
+ #>
28
+ [CmdletBinding()]
29
+ param()
30
+
31
+ $script:ParallelCheckpointPath = 'artifacts/orchestration/parallel-orchestrator-state.json'
32
+ $script:AllowedMergeStatuses = @('merged', 'worktree_removed')
33
+
34
+ function Get-ParallelWorktreeRemovalGateCheckpointContent {
35
+ <#
36
+ .SYNOPSIS
37
+ Read the raw JSON text of the parallel checkpoint. Tests mock this function
38
+ (read seam).
39
+ .OUTPUTS
40
+ System.String or $null
41
+ #>
42
+ [CmdletBinding()]
43
+ [OutputType([string])]
44
+ param()
45
+
46
+ if (-not (Test-Path -LiteralPath $script:ParallelCheckpointPath -PathType Leaf)) {
47
+ return $null
48
+ }
49
+ return (Get-Content -LiteralPath $script:ParallelCheckpointPath -Raw)
50
+ }
51
+
52
+ function Get-ParallelWorktreeRemovalCommandPath {
53
+ <#
54
+ .SYNOPSIS
55
+ Extract the target worktree path argument from a git worktree remove command.
56
+ .PARAMETER CommandText
57
+ The Bash command text under evaluation.
58
+ .OUTPUTS
59
+ System.String or $null
60
+ #>
61
+ [CmdletBinding()]
62
+ [OutputType([string])]
63
+ param(
64
+ [Parameter(Mandatory)]
65
+ [string] $CommandText
66
+ )
67
+
68
+ if ($CommandText -match '(?i)\bgit\s+worktree\s+remove\s+(?<path>\S+)') {
69
+ return $Matches['path'].Trim('"''')
70
+ }
71
+ return $null
72
+ }
73
+
74
+ function Find-ParallelWorktreeItemRecord {
75
+ <#
76
+ .SYNOPSIS
77
+ Locate the items[] record whose worktree_path matches the target path.
78
+ .PARAMETER Checkpoint
79
+ Parsed parallel checkpoint, or $null when absent/unreadable.
80
+ .PARAMETER WorktreePath
81
+ The target worktree path extracted from the command text.
82
+ .OUTPUTS
83
+ System.Object or $null
84
+ #>
85
+ [CmdletBinding()]
86
+ param(
87
+ [AllowNull()]
88
+ $Checkpoint,
89
+
90
+ [AllowNull()]
91
+ [string] $WorktreePath
92
+ )
93
+
94
+ if ($null -eq $Checkpoint -or [string]::IsNullOrWhiteSpace($WorktreePath)) {
95
+ return $null
96
+ }
97
+ $checkpointProps = @($Checkpoint.PSObject.Properties.Name)
98
+ if ($checkpointProps -notcontains 'items') {
99
+ return $null
100
+ }
101
+
102
+ $normalizedTarget = ($WorktreePath -replace '\\', '/').TrimEnd('/')
103
+
104
+ # Scan every recorded item for a worktree_path that matches the removal target;
105
+ # path separators are normalized so Windows- and POSIX-style paths compare equal.
106
+ foreach ($item in @($Checkpoint.items)) {
107
+ $itemProps = @($item.PSObject.Properties.Name)
108
+ if ($itemProps -notcontains 'worktree_path') {
109
+ continue
110
+ }
111
+ $normalizedItemPath = (([string]$item.worktree_path) -replace '\\', '/').TrimEnd('/')
112
+ if ($normalizedItemPath -eq $normalizedTarget) {
113
+ return $item
114
+ }
115
+ }
116
+ return $null
117
+ }
118
+
119
+ function Test-ParallelWorktreeRemovalAllowed {
120
+ <#
121
+ .SYNOPSIS
122
+ Decision logic: allow only when the matching item record's merge_status is
123
+ merged or worktree_removed.
124
+ .PARAMETER ItemRecord
125
+ The matched items[] record, or $null when no match was found.
126
+ .OUTPUTS
127
+ System.Boolean
128
+ #>
129
+ [CmdletBinding()]
130
+ [OutputType([bool])]
131
+ param(
132
+ [AllowNull()]
133
+ $ItemRecord
134
+ )
135
+
136
+ if ($null -eq $ItemRecord) {
137
+ return $false
138
+ }
139
+ $props = @($ItemRecord.PSObject.Properties.Name)
140
+ if ($props -notcontains 'merge_status') {
141
+ return $false
142
+ }
143
+ return $script:AllowedMergeStatuses -contains ([string]$ItemRecord.merge_status)
144
+ }
145
+
146
+ function Get-ParallelWorktreeGateAllowDecision {
147
+ [CmdletBinding()]
148
+ [OutputType([System.Collections.Specialized.OrderedDictionary])]
149
+ param()
150
+
151
+ return [ordered]@{
152
+ hookSpecificOutput = [ordered]@{
153
+ hookEventName = 'PreToolUse'
154
+ permissionDecision = 'allow'
155
+ }
156
+ }
157
+ }
158
+
159
+ function Get-ParallelWorktreeGateBlockDecision {
160
+ [CmdletBinding()]
161
+ [OutputType([System.Collections.Specialized.OrderedDictionary])]
162
+ param(
163
+ [Parameter(Mandatory)]
164
+ [string] $Reason
165
+ )
166
+
167
+ return [ordered]@{
168
+ hookSpecificOutput = [ordered]@{
169
+ hookEventName = 'PreToolUse'
170
+ permissionDecision = 'deny'
171
+ permissionDecisionReason = $Reason
172
+ }
173
+ }
174
+ }
175
+
176
+ function Invoke-ParallelWorktreeRemovalGateDecision {
177
+ <#
178
+ .SYNOPSIS
179
+ Parses CLAUDE_TOOL_INPUT and returns an allow-or-block decision.
180
+ .PARAMETER ToolInputRaw
181
+ The raw JSON tool payload supplied by Claude Code.
182
+ .OUTPUTS
183
+ System.Collections.Specialized.OrderedDictionary
184
+ #>
185
+ [CmdletBinding()]
186
+ [OutputType([System.Collections.Specialized.OrderedDictionary])]
187
+ param(
188
+ [string] $ToolInputRaw
189
+ )
190
+
191
+ if (-not $ToolInputRaw) {
192
+ return Get-ParallelWorktreeGateAllowDecision
193
+ }
194
+
195
+ try {
196
+ $toolInput = $ToolInputRaw | ConvertFrom-Json -ErrorAction Stop
197
+ } catch {
198
+ throw "enforce-parallel-worktree-removal-gate hook received malformed JSON in CLAUDE_TOOL_INPUT: $_"
199
+ }
200
+
201
+ $commandText = $toolInput.command
202
+ if (-not $commandText) {
203
+ return Get-ParallelWorktreeGateAllowDecision
204
+ }
205
+
206
+ if ($commandText -notmatch '(?i)\bgit\s+worktree\s+remove\b') {
207
+ return Get-ParallelWorktreeGateAllowDecision
208
+ }
209
+
210
+ $worktreePath = Get-ParallelWorktreeRemovalCommandPath -CommandText $commandText
211
+
212
+ $checkpointRaw = Get-ParallelWorktreeRemovalGateCheckpointContent
213
+ $checkpoint = $null
214
+ if (-not [string]::IsNullOrWhiteSpace($checkpointRaw)) {
215
+ try {
216
+ $checkpoint = $checkpointRaw | ConvertFrom-Json -ErrorAction Stop
217
+ } catch {
218
+ $checkpoint = $null
219
+ }
220
+ }
221
+
222
+ $itemRecord = Find-ParallelWorktreeItemRecord -Checkpoint $checkpoint -WorktreePath $worktreePath
223
+ if (Test-ParallelWorktreeRemovalAllowed -ItemRecord $itemRecord) {
224
+ return Get-ParallelWorktreeGateAllowDecision
225
+ }
226
+
227
+ return Get-ParallelWorktreeGateBlockDecision -Reason "PARALLEL_WORKTREE_REMOVAL_BLOCKED: git worktree remove for '$worktreePath' requires a matching parallel checkpoint items[] record with merge_status in {merged, worktree_removed}. The checkpoint was unreadable, no matching record was found, or merge_status was not yet safe for removal."
228
+ }
229
+
230
+ # Guard allows dot-sourcing in tests without executing the entrypoint.
231
+ if ($MyInvocation.InvocationName -eq '.') {
232
+ return
233
+ }
234
+
235
+ try {
236
+ $decision = Invoke-ParallelWorktreeRemovalGateDecision -ToolInputRaw $env:CLAUDE_TOOL_INPUT
237
+ } catch {
238
+ Write-Error $_
239
+ exit 1
240
+ }
241
+
242
+ $decision | ConvertTo-Json -Compress -Depth 5 | Write-Output
243
+
244
+ exit 0
@@ -0,0 +1,379 @@
1
+ <#
2
+ .SYNOPSIS
3
+ Blast-radius derivation and the fail-closed contention relation.
4
+
5
+ .DESCRIPTION
6
+ Destination-runtime PowerShell facade for the blast-radius library, porting
7
+ scripts/dev_tools/compute_blast_radius.py (derive_blast_radius,
8
+ radius_from_observed_paths, _feature_folder_glob) and
9
+ scripts/dev_tools/_blast_radius_conflicts.py (conflicts,
10
+ _smallest_path_overlap, _smallest_common). It imports the extraction, glob,
11
+ truth-table, and validation modules that sit beside it and re-exports the
12
+ five functions the spec PowerShell surface fixes:
13
+
14
+ - Get-PlanPaths port of extract_plan_paths
15
+ - Get-BlastRadius port of derive_blast_radius
16
+ - Get-BlastRadiusFromObservedPaths port of radius_from_observed_paths
17
+ - Test-BlastRadius port of validate_blast_radius
18
+ - Test-BlastRadiusConflict port of conflicts
19
+
20
+ The Python modules remain the authoritative reference implementation. This
21
+ module is one half of a two-language mirror; it never imports validator
22
+ logic. Every function is pure: no filesystem, subprocess, network, or
23
+ wall-clock access, and no input is mutated. computed_at is caller supplied,
24
+ so the library never reads the clock.
25
+
26
+ Parity notes for maintainers:
27
+ - A radius is a hashtable whose key set is exactly paths, modules,
28
+ shared_surfaces, contracts, source, and computed_at. Hashtable key order
29
+ is not significant; the key set and the values are the contract.
30
+ - Every function in this library that mirrors a Python tuple return writes
31
+ its elements to the pipeline, following the repository's
32
+ Get-PoshQCFileList convention. Callers must wrap such a call in @(...) to
33
+ obtain an array, because a zero-element result writes nothing and a
34
+ one-element result writes a single object. Test-BlastRadius is the one
35
+ collection-returning function on this facade; Get-BlastRadius,
36
+ Get-BlastRadiusFromObservedPaths, and Test-BlastRadiusConflict each
37
+ return a single hashtable.
38
+ - source is restricted to derived, declared, and observed; anything else,
39
+ and any malformed input, throws.
40
+ - The contention relation fails closed: a glob pair that cannot be proven
41
+ disjoint counts as overlapping, because radius under-reporting is the
42
+ dominant risk of the parallel design.
43
+ - Reasons are reported in the fixed kind order path_overlap,
44
+ module_overlap, shared_surface_overlap, contract_dependency, and each
45
+ detail is order-normalized so the relation is observably symmetric in its
46
+ two arguments.
47
+ - Two empty radii, and an empty radius against a non-empty one, do not
48
+ conflict. Under-reporting via emptiness is V1's problem at plan time, not
49
+ the relation's.
50
+ #>
51
+
52
+ Set-StrictMode -Version Latest
53
+
54
+ Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusExtraction.psm1') -Force
55
+ Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusGlob.psm1') -Force
56
+ Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusConfig.psm1') -Force
57
+ Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusValidation.psm1') -Force
58
+
59
+ # Feature-folder handling. Every radius contains its own feature folder, and a
60
+ # caller may pass either a bare folder name or an already-qualified path.
61
+ $script:FeatureFolderRoot = 'docs/features/active'
62
+ $script:FeatureFolderPrefix = 'docs/features/'
63
+
64
+ # The default confidence source for derivation, and the source a diff-derived
65
+ # radius always records.
66
+ $script:SourceDerived = 'derived'
67
+ $script:SourceObserved = 'observed'
68
+
69
+ # Contention reason kinds, in the fixed order every result reports them. These
70
+ # strings are contract literals consumed by the downstream parallel schema.
71
+ $script:ConflictPathOverlap = 'path_overlap'
72
+ $script:ConflictModuleOverlap = 'module_overlap'
73
+ $script:ConflictSharedSurfaceOverlap = 'shared_surface_overlap'
74
+ $script:ConflictContractDependency = 'contract_dependency'
75
+
76
+ # Separator used in an overlapping-pair detail string. The pair is ordered
77
+ # ordinally before formatting so the detail is identical in both argument orders.
78
+ $script:PairDetailSeparator = ' ~ '
79
+
80
+
81
+ # Port of _feature_folder_glob. Accepting an already-qualified path avoids
82
+ # producing a doubled docs/features/active/docs/features/active/... entry when a
83
+ # caller passes the folder path it already holds.
84
+ function Get-FeatureFolderGlob {
85
+ [CmdletBinding()]
86
+ [OutputType([string])]
87
+ param(
88
+ [Parameter(Mandatory = $true)]
89
+ [string] $FeatureFolder
90
+ )
91
+
92
+ $trimmed = $FeatureFolder.Trim().Trim('/')
93
+ if ($trimmed.StartsWith($script:FeatureFolderPrefix, [System.StringComparison]::Ordinal)) {
94
+ return "$trimmed/**"
95
+ }
96
+
97
+ return "$script:FeatureFolderRoot/$trimmed/**"
98
+ }
99
+
100
+ function Get-BlastRadius {
101
+ <#
102
+ .SYNOPSIS
103
+ Derive a blast radius from an approved plan and its feature spec.
104
+
105
+ .DESCRIPTION
106
+ Port of derive_blast_radius. Plan task bodies are the primary signal, the
107
+ spec contributes the paths it cites in inline code, and the feature
108
+ folder is always present because every work item writes its own documents
109
+ and evidence. A plan and spec with no extractable paths still yield a
110
+ radius containing the feature-folder glob.
111
+
112
+ .PARAMETER PlanText
113
+ Approved atomic-plan document text; may be empty.
114
+
115
+ .PARAMETER SpecText
116
+ Feature spec.md document text; may be empty.
117
+
118
+ .PARAMETER FeatureFolder
119
+ Bare feature folder name, or a path that already starts with
120
+ docs/features/.
121
+
122
+ .PARAMETER Config
123
+ Parsed config/blast-radius.json.
124
+
125
+ .PARAMETER Source
126
+ Confidence source to record; derived by default and declared when a
127
+ planner adopts the result as authoritative.
128
+
129
+ .PARAMETER ComputedAt
130
+ Caller-supplied ISO-8601 timestamp. The library never reads the clock.
131
+
132
+ .OUTPUTS
133
+ System.Collections.Hashtable. The derived radius, carrying exactly the
134
+ keys paths, modules, shared_surfaces, contracts, source, and computed_at.
135
+ #>
136
+ [CmdletBinding()]
137
+ [OutputType([hashtable])]
138
+ param(
139
+ [Parameter(Mandatory = $true)]
140
+ [AllowEmptyString()]
141
+ [string] $PlanText,
142
+ [Parameter(Mandatory = $true)]
143
+ [AllowEmptyString()]
144
+ [string] $SpecText,
145
+ [Parameter(Mandatory = $true)]
146
+ [AllowEmptyString()]
147
+ [string] $FeatureFolder,
148
+ [Parameter(Mandatory = $true)]
149
+ [AllowNull()]
150
+ [object] $Config,
151
+ [string] $Source = $script:SourceDerived,
152
+ [Parameter(Mandatory = $true)]
153
+ [AllowEmptyString()]
154
+ [string] $ComputedAt
155
+ )
156
+
157
+ [void](Get-RequiredText -Value $PlanText -FieldName 'plan_text' -AllowEmpty)
158
+ [void](Get-RequiredText -Value $SpecText -FieldName 'spec_text' -AllowEmpty)
159
+
160
+ # Both extraction calls read the separator-free root-surface set from the
161
+ # same -Config value that resolves modules and shared surfaces below. Sharing
162
+ # one reader with Test-BlastRadius is what preserves the invariant that a
163
+ # derived radius always passes V1 and V2 against its own plan (issue #452).
164
+ $rootSurface = [string[]]@(Get-ConfigRootSurface -Config $Config)
165
+
166
+ $specLine = [string[]]@(ConvertTo-NormalizedLine -Text $SpecText)
167
+ $entry = [System.Collections.Generic.List[string]]::new()
168
+ $entry.AddRange([string[]]@(Get-PlanPaths -PlanText $PlanText -RootSurface $rootSurface))
169
+ $entry.AddRange([string[]]@(Get-PathFromLine -Line $specLine -RootSurface $rootSurface))
170
+ $entry.Add((Get-FeatureFolderGlob -FeatureFolder (
171
+ Get-RequiredText -Value $FeatureFolder -FieldName 'feature_folder')))
172
+
173
+ $paths = [string[]]@(Get-OrdinalSortedEntry -Entry $entry.ToArray())
174
+ $concrete = [string[]]@(Get-ConcreteEntry -Entry $paths)
175
+
176
+ return ConvertTo-NormalizedBlastRadius -Radius @{
177
+ paths = $paths
178
+ modules = @(Resolve-BlastRadiusModule -PathEntry $paths -Config $Config)
179
+ shared_surfaces = @(Resolve-BlastRadiusSharedSurface -ConcretePath $concrete -Config $Config)
180
+ contracts = @(Get-ContractIdentifier -SpecText $SpecText)
181
+ source = $Source
182
+ computed_at = $ComputedAt
183
+ }
184
+ }
185
+
186
+ function Get-BlastRadiusFromObservedPaths {
187
+ <#
188
+ .SYNOPSIS
189
+ Build an observed-source radius from an already-collected path list.
190
+
191
+ .DESCRIPTION
192
+ Port of radius_from_observed_paths. Drift detection supplies the output
193
+ of a diff listing; the library performs no subprocess call of its own, so
194
+ the paths arrive as plain strings and are taken verbatim rather than
195
+ re-classified by the plan-text heuristic. contracts is empty because a
196
+ diff carries no interface-section text.
197
+
198
+ .PARAMETER ObservedPaths
199
+ Repository-relative paths from a diff, as a collection. A bare string is
200
+ rejected, matching the Python reference guard.
201
+
202
+ .PARAMETER Config
203
+ Parsed config/blast-radius.json.
204
+
205
+ .PARAMETER ComputedAt
206
+ Caller-supplied ISO-8601 timestamp. The library never reads the clock.
207
+
208
+ .OUTPUTS
209
+ System.Collections.Hashtable. A radius whose source is observed and whose
210
+ modules and shared surfaces are resolved by the derivation rules.
211
+ #>
212
+ [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'The exported name is fixed by the spec PowerShell surface contract for issue #447 and mirrors radius_from_observed_paths.')]
213
+ [CmdletBinding()]
214
+ [OutputType([hashtable])]
215
+ param(
216
+ [Parameter(Mandatory = $true)]
217
+ [AllowNull()]
218
+ [object] $ObservedPaths,
219
+ [Parameter(Mandatory = $true)]
220
+ [AllowNull()]
221
+ [object] $Config,
222
+ [Parameter(Mandatory = $true)]
223
+ [AllowEmptyString()]
224
+ [string] $ComputedAt
225
+ )
226
+
227
+ $paths = [string[]]@(Get-RequiredStringList -Value $ObservedPaths -FieldName 'observed_paths')
228
+ $concrete = [string[]]@(Get-ConcreteEntry -Entry $paths)
229
+
230
+ return ConvertTo-NormalizedBlastRadius -Radius @{
231
+ paths = $paths
232
+ modules = @(Resolve-BlastRadiusModule -PathEntry $paths -Config $Config)
233
+ shared_surfaces = @(Resolve-BlastRadiusSharedSurface -ConcretePath $concrete -Config $Config)
234
+ contracts = @()
235
+ source = $script:SourceObserved
236
+ computed_at = $ComputedAt
237
+ }
238
+ }
239
+
240
+ # Port of _smallest_path_overlap. Each overlapping pair is ordered before it is
241
+ # recorded, so the minimum is taken over a set that does not depend on argument
242
+ # order; that is what makes the reported detail symmetric.
243
+ function Get-SmallestPathOverlap {
244
+ [CmdletBinding()]
245
+ [OutputType([string])]
246
+ param(
247
+ [Parameter(Mandatory = $true)]
248
+ [AllowEmptyCollection()]
249
+ [AllowEmptyString()]
250
+ [string[]] $PathA,
251
+ [Parameter(Mandatory = $true)]
252
+ [AllowEmptyCollection()]
253
+ [AllowEmptyString()]
254
+ [string[]] $PathB
255
+ )
256
+
257
+ $detail = [System.Collections.Generic.List[string]]::new()
258
+ foreach ($entryA in $PathA) {
259
+ foreach ($entryB in $PathB) {
260
+ if (-not (Test-EntryOverlap -EntryA $entryA -EntryB $entryB)) {
261
+ continue
262
+ }
263
+ $ordered = if ([string]::CompareOrdinal($entryA, $entryB) -le 0) {
264
+ @($entryA, $entryB)
265
+ } else {
266
+ @($entryB, $entryA)
267
+ }
268
+ $detail.Add($ordered -join $script:PairDetailSeparator)
269
+ }
270
+ }
271
+
272
+ return (Get-OrdinalSmallestEntry -Entry $detail.ToArray())
273
+ }
274
+
275
+ # Port of _smallest_common. Two empty collections share nothing, so the result is
276
+ # $null and the level contributes no reason.
277
+ function Get-SmallestCommonEntry {
278
+ [CmdletBinding()]
279
+ [OutputType([string])]
280
+ param(
281
+ [Parameter(Mandatory = $true)]
282
+ [AllowEmptyCollection()]
283
+ [AllowEmptyString()]
284
+ [string[]] $Left,
285
+ [Parameter(Mandatory = $true)]
286
+ [AllowEmptyCollection()]
287
+ [AllowEmptyString()]
288
+ [string[]] $Right
289
+ )
290
+
291
+ $rightSet = [System.Collections.Generic.HashSet[string]]::new($Right, [StringComparer]::Ordinal)
292
+ $common = [System.Collections.Generic.List[string]]::new()
293
+ foreach ($entry in $Left) {
294
+ if ($rightSet.Contains($entry)) {
295
+ $common.Add($entry)
296
+ }
297
+ }
298
+
299
+ return (Get-OrdinalSmallestEntry -Entry $common.ToArray())
300
+ }
301
+
302
+ function Test-BlastRadiusConflict {
303
+ <#
304
+ .SYNOPSIS
305
+ Decide whether two radii contend, and report every triggered disjunct.
306
+
307
+ .DESCRIPTION
308
+ Port of conflicts. Evaluates the four disjuncts and returns the verdict
309
+ plus one reason per triggered level in the fixed kind order path_overlap,
310
+ module_overlap, shared_surface_overlap, contract_dependency. The three
311
+ set-intersection levels differ only in which collection they read, so one
312
+ pass over the level table keeps them in the required order without
313
+ repeating the intersection logic.
314
+
315
+ .PARAMETER RadiusA
316
+ First radius record.
317
+
318
+ .PARAMETER RadiusB
319
+ Second radius record.
320
+
321
+ .PARAMETER Config
322
+ Parsed config/blast-radius.json. The relation reads no key from it today;
323
+ it is validated and kept in the signature because the contract is frozen
324
+ for downstream consumers.
325
+
326
+ .OUTPUTS
327
+ System.Collections.Hashtable. Keys conflict (a boolean) and reasons (an
328
+ array of hashtables with keys kind and detail).
329
+ #>
330
+ [CmdletBinding()]
331
+ [OutputType([hashtable])]
332
+ param(
333
+ [Parameter(Mandatory = $true)]
334
+ [AllowNull()]
335
+ [object] $RadiusA,
336
+ [Parameter(Mandatory = $true)]
337
+ [AllowNull()]
338
+ [object] $RadiusB,
339
+ [Parameter(Mandatory = $true)]
340
+ [AllowNull()]
341
+ [object] $Config
342
+ )
343
+
344
+ [void](Get-RequiredMapping -Value $Config -FieldName 'config')
345
+ $left = ConvertTo-NormalizedBlastRadius -Radius $RadiusA
346
+ $right = ConvertTo-NormalizedBlastRadius -Radius $RadiusB
347
+
348
+ $reason = [System.Collections.Generic.List[hashtable]]::new()
349
+ $pathDetail = Get-SmallestPathOverlap -PathA ([string[]]@($left['paths'])) `
350
+ -PathB ([string[]]@($right['paths']))
351
+ if ($null -ne $pathDetail) {
352
+ $reason.Add(@{ kind = $script:ConflictPathOverlap; detail = $pathDetail })
353
+ }
354
+
355
+ $level = @(
356
+ @{ kind = $script:ConflictModuleOverlap; key = 'modules' },
357
+ @{ kind = $script:ConflictSharedSurfaceOverlap; key = 'shared_surfaces' },
358
+ @{ kind = $script:ConflictContractDependency; key = 'contracts' }
359
+ )
360
+ foreach ($entry in $level) {
361
+ $shared = Get-SmallestCommonEntry -Left ([string[]]@($left[$entry['key']])) `
362
+ -Right ([string[]]@($right[$entry['key']]))
363
+ if ($null -ne $shared) {
364
+ $reason.Add(@{ kind = $entry['kind']; detail = $shared })
365
+ }
366
+ }
367
+
368
+ return @{
369
+ conflict = ($reason.Count -gt 0)
370
+ reasons = @($reason.ToArray())
371
+ }
372
+ }
373
+
374
+ Export-ModuleMember -Function `
375
+ Get-PlanPaths, `
376
+ Get-BlastRadius, `
377
+ Get-BlastRadiusFromObservedPaths, `
378
+ Test-BlastRadius, `
379
+ Test-BlastRadiusConflict