@danmoisan/drm-copilot-mcp 1.1.9 → 1.1.11

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 (46) hide show
  1. package/out/mcp-server.js +3526 -1037
  2. package/package.json +1 -1
  3. package/resources/claude-customizations/.claude/agents/parallel-orchestrator.md +15 -2
  4. package/resources/claude-customizations/.claude/agents/parallel-planner.md +3 -0
  5. package/resources/claude-customizations/.claude/hooks/enforce-epic-merge-gate.ps1 +49 -14
  6. package/resources/claude-customizations/.claude/hooks/enforce-epic-worktree-removal-gate.ps1 +52 -3
  7. package/resources/claude-customizations/.claude/hooks/enforce-orchestration-preimplementation-gate.ps1 +7 -1
  8. package/resources/claude-customizations/.claude/hooks/enforce-parallel-abandon-gate.ps1 +103 -6
  9. package/resources/claude-customizations/.claude/hooks/enforce-parallel-worktree-removal-gate.ps1 +58 -3
  10. package/resources/claude-customizations/.claude/hooks/enforce-pr-author-skill-helpers.ps1 +37 -5
  11. package/resources/claude-customizations/.claude/hooks/enforce-pr-author-skill.epic-base-branch.ps1 +13 -2
  12. package/resources/claude-customizations/.claude/hooks/enforce-promotion-mcp-only.ps1 +35 -7
  13. package/resources/claude-customizations/.claude/hooks/hook-command-invocation.ps1 +483 -0
  14. package/resources/claude-customizations/.claude/hooks/hook-command-scanner.ps1 +483 -0
  15. package/resources/claude-customizations/.claude/hooks/validate-bash.ps1 +254 -5
  16. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadius.psm1 +18 -75
  17. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusConflict.psm1 +290 -0
  18. package/resources/claude-customizations/.claude/lib/cleanup-manifest/CleanupWorktreeManifest.psm1 +415 -0
  19. package/resources/claude-customizations/.claude/lib/project-file-merge/ProjectFileMerge.psm1 +355 -0
  20. package/resources/claude-customizations/.claude/lib/project-file-merge/ProjectFileMergeGrammar.psm1 +318 -0
  21. package/resources/claude-customizations/.claude/lib/project-file-merge/Resolve-MergeableConflict.ps1 +229 -0
  22. package/resources/claude-customizations/.claude/rules/parallel-orchestration.md +66 -2
  23. package/resources/claude-customizations/.claude/skills/cleanup-merged-worktrees/SKILL.md +311 -16
  24. package/resources/claude-customizations/.claude/skills/orchestrate/SKILL.md +42 -0
  25. package/resources/claude-customizations/.claude/skills/parallel-add/SKILL.md +3 -1
  26. package/resources/claude-customizations/.claude/skills/parallel-orchestrate/SKILL.md +36 -2
  27. package/resources/claude-customizations/.claude/skills/parallel-plan/SKILL.md +6 -0
  28. package/resources/claude-customizations/.claude/skills/powershell-orchestration-state-machine/SKILL.md +29 -1
  29. package/resources/claude-customizations/config/blast-radius.json +7 -0
  30. package/resources/claude-customizations/pack-manifests/core.json +7 -0
  31. package/resources/codex-and-agents-customizations/.agents/skills/orchestrate/SKILL.md +41 -0
  32. package/resources/codex-and-agents-customizations/.agents/skills/orchestrator-state/SKILL.md +41 -0
  33. package/resources/codex-and-agents-customizations/.agents/skills/repo-automation-adapter/SKILL.md +26 -0
  34. package/resources/codex-and-agents-customizations/.codex/config.toml +1 -1
  35. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-epic-merge-gate.ps1 +49 -3
  36. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-epic-planning-only.ps1 +57 -12
  37. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-epic-worktree-removal-gate.ps1 +34 -8
  38. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-orchestration-preimplementation-gate.ps1 +6 -1
  39. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-promotion-mcp-only.ps1 +34 -7
  40. package/resources/codex-and-agents-customizations/.codex/hooks/hook-command-invocation.ps1 +483 -0
  41. package/resources/codex-and-agents-customizations/.codex/hooks/hook-command-scanner.ps1 +483 -0
  42. package/resources/codex-and-agents-customizations/.codex/hooks/validate-bash.ps1 +130 -2
  43. package/resources/codex-and-agents-customizations/pack-manifests/core.json +6 -1
  44. package/resources/config/orchestration-handoff-registry.json +138 -0
  45. package/resources/config/orchestration-handoff.schema.json +472 -0
  46. package/resources/powershell/PoshQC/settings/pester.runsettings.psd1 +38 -0
@@ -39,6 +39,11 @@ param(
39
39
  )
40
40
 
41
41
  Import-Module (Join-Path $PSScriptRoot '../lib/hook-payload/HookPayload.psm1') -Force
42
+ # Shared command-line parser (issue #545). Both detectors below run against the segment
43
+ # list rather than against the unsegmented command string, so a dangerous phrase quoted
44
+ # inside a message body is no longer a match and a relocating spelling no longer escapes.
45
+ . (Join-Path $PSScriptRoot 'hook-command-scanner.ps1')
46
+ . (Join-Path $PSScriptRoot 'hook-command-invocation.ps1')
42
47
 
43
48
  function Get-BlockedBashPattern {
44
49
  [CmdletBinding()]
@@ -55,7 +60,119 @@ function Get-BlockedBashPattern {
55
60
  )
56
61
  }
57
62
 
63
+ function Test-BlockedPatternTokenRun {
64
+ <#
65
+ .SYNOPSIS
66
+ Report whether a literal's token sequence occurs as a contiguous run in a token list.
67
+ .DESCRIPTION
68
+ Whole-token equality, element by element. This is the comparison primitive that
69
+ replaces String.Contains. It is what makes '--force-with-lease' stop matching the
70
+ literal 'git push --force': the two are different tokens, whereas one is a substring
71
+ of the other.
72
+ .OUTPUTS
73
+ System.Boolean
74
+ #>
75
+ [CmdletBinding()]
76
+ [OutputType([bool])]
77
+ param(
78
+ [Parameter(Mandatory)][AllowEmptyCollection()][string[]] $Token,
79
+ [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string[]] $PatternToken
80
+ )
81
+
82
+ if ($PatternToken.Count -eq 0 -or $Token.Count -lt $PatternToken.Count) {
83
+ return $false
84
+ }
85
+
86
+ for ($start = 0; $start -le $Token.Count - $PatternToken.Count; $start++) {
87
+ $matched = $true
88
+ for ($offset = 0; $offset -lt $PatternToken.Count; $offset++) {
89
+ if ($Token[$start + $offset] -ne $PatternToken[$offset]) {
90
+ $matched = $false
91
+ break
92
+ }
93
+ }
94
+ if ($matched) {
95
+ return $true
96
+ }
97
+ }
98
+
99
+ return $false
100
+ }
101
+
102
+ function Get-BlockedStructuralGitMatch {
103
+ <#
104
+ .SYNOPSIS
105
+ Report the denylist literal a segment's structural git invocation stands for, or $null.
106
+ .DESCRIPTION
107
+ The flag conjunction is required rather than optional. Classifying on the bare
108
+ subcommand would deny 'git push --force-with-lease origin HEAD', which spec Test
109
+ Strategy row AT-8 requires to allow, and would deny 'git reset --soft HEAD~1'.
110
+
111
+ When a segment carries both '--force' and '-f' the '--force' leg is evaluated first,
112
+ so the returned value is 'git push --force'.
113
+ .OUTPUTS
114
+ System.String or $null
115
+ #>
116
+ [CmdletBinding()]
117
+ [OutputType([string])]
118
+ param(
119
+ [Parameter(Mandatory)][AllowEmptyString()][string] $SegmentText,
120
+ [Parameter(Mandatory)][AllowEmptyCollection()][string[]] $Token
121
+ )
122
+
123
+ if (Test-CommandLineInvocation -CommandText $SegmentText -CommandWord 'git' -SubcommandPath @('push')) {
124
+ if ($Token -contains '--force') { return 'git push --force' }
125
+ if ($Token -contains '-f') { return 'git push -f' }
126
+ }
127
+ if (Test-CommandLineInvocation -CommandText $SegmentText -CommandWord 'git' -SubcommandPath @('reset')) {
128
+ if ($Token -contains '--hard') { return 'git reset --hard' }
129
+ }
130
+
131
+ return $null
132
+ }
133
+
58
134
  function Get-BlockedPatternMatch {
135
+ <#
136
+ .SYNOPSIS
137
+ Return the denylist literal a command matches, or $null.
138
+ .DESCRIPTION
139
+ Two legs, in this order, over the segment list produced by Read-CommandLineSegment.
140
+
141
+ Leg 1 (literal): each of the six byte-unchanged literals from Get-BlockedBashPattern
142
+ is split into its own whitespace-delimited token sequence and reported as a match
143
+ when that sequence occurs as a CONTIGUOUS RUN inside a segment's Tokens, every
144
+ element compared by whole-token equality. The value returned is the literal string
145
+ itself, and the literals are tested in their existing declaration order. Because a
146
+ quoted span is one token, 'git commit -m "why rm -rf is banned"' no longer matches.
147
+
148
+ Leg 1 has a second condition, the wrapper carve-out of D2 Piece 2 together with the two
149
+ other clauses under which the scanner selects raw scan text. A wrapper's quoted argument is
150
+ a nested command line rather than inert data, and ConvertTo-CommandLineToken collapses a
151
+ balanced quoted span into ONE token, so a multi-token literal can never form a contiguous
152
+ token run inside it. The same masking hides a literal carried inside a quoted span or a
153
+ heredoc that never closes. For a segment that is wrapper-led, that carries a live
154
+ substitution, or whose quoting or heredoc did not close, the scanner already selects
155
+ RawText as ScanText, and this leg reads that field with an Ordinal IndexOf, matching the
156
+ culture-insensitive String.Contains it replaced. The three disjuncts here are exactly the
157
+ three clauses of that ScanText selection, so no segment the scanner scans raw is left
158
+ unscanned by this leg. That is what keeps both 'bash -c "rm -rf /tmp/x"' and
159
+ 'echo "rm -rf /tmp/x' denying. A segment matching none of the three is never scanned this
160
+ way, so 'git commit -m "docs: explain why rm -rf is banned"' still allows.
161
+
162
+ Leg 2 (structural): a relocating spelling such as 'git -C ../wt push --force origin
163
+ HEAD' contains no literal as a token run, so it is classified structurally instead.
164
+
165
+ Leg 1 is evaluated IN FULL, over every segment and every literal, before any leg 2
166
+ evaluation. That ordering is what keeps 'git push origin --force' returning the
167
+ literal 'git push origin --force' rather than the leg 2 value 'git push --force',
168
+ which is what the existing case at tests/scripts/claude-hooks/validate-bash.Tests.ps1
169
+ line 29 asserts.
170
+
171
+ Spec D11.3 rules that rule R2 governs the literal TEXT, not the comparison operator,
172
+ so all six literals stay byte-unchanged; only the comparison primitive changed.
173
+ .OUTPUTS
174
+ System.String or $null
175
+ #>
59
176
  [CmdletBinding()]
60
177
  [OutputType([string])]
61
178
  param(
@@ -69,12 +186,139 @@ function Get-BlockedPatternMatch {
69
186
  return $null
70
187
  }
71
188
 
189
+ $segments = @(Read-CommandLineSegment -CommandText $Command)
190
+
72
191
  foreach ($pattern in (Get-BlockedBashPattern)) {
73
- if ($Command.Contains($pattern)) {
74
- return $pattern
192
+ $patternTokens = [string[]]@($pattern -split '\s+' | Where-Object { $_ })
193
+ foreach ($segment in $segments) {
194
+ if (Test-BlockedPatternTokenRun -Token @($segment.Tokens) -PatternToken $patternTokens) {
195
+ return $pattern
196
+ }
197
+ if (($segment.IsWrapperLed -or $segment.HasLiveSubstitution -or $segment.Unbalanced) -and
198
+ $segment.ScanText.IndexOf($pattern, [System.StringComparison]::Ordinal) -ge 0) {
199
+ return $pattern
200
+ }
201
+ }
202
+ }
203
+
204
+ foreach ($segment in $segments) {
205
+ $structural = Get-BlockedStructuralGitMatch -SegmentText $segment.RawText -Token @($segment.Tokens)
206
+ if ($structural) {
207
+ return $structural
208
+ }
209
+ }
210
+
211
+ return $null
212
+ }
213
+
214
+ # File-reading commands Claude Code's Bash permission engine resolves against
215
+ # Read() rules rather than Bash() prefix rules. Measured empirically (2026-09):
216
+ # once one of these co-occurs with a preceding `cd` in the same command line
217
+ # (chained via `&&` or `;`), the engine always requires manual approval -
218
+ # regardless of any Read() or Bash() allow rule present, and regardless of
219
+ # whether the read command's own path argument is relative or absolute. No
220
+ # settings.json configuration can suppress that prompt; the only fix is to
221
+ # never chain `cd` with one of these in the same command.
222
+ $script:CdChainedReadCommandPattern = 'cd\s+\S.*?(&&|;)\s*(grep|cat|head|tail|less|more|awk|sed\s+-n)\b'
223
+
224
+ # The read-command family the pattern above enumerates, as command words. Retained
225
+ # alongside the pattern so the rule R2 literal-text obligation stays visibly discharged:
226
+ # the pattern string is byte-unchanged and is kept as a declared constant, while the
227
+ # evaluation moves to the segment list.
228
+ $script:CdChainedReadCommandWords = @('grep', 'cat', 'head', 'tail', 'less', 'more', 'awk', 'sed')
229
+
230
+ function Get-CdChainedReadCommandMatch {
231
+ <#
232
+ .SYNOPSIS
233
+ Return the read command chained after a cd on the same command line, or $null.
234
+ .DESCRIPTION
235
+ Walks the segment list in order and reports a match when a segment whose CommandWord
236
+ is 'cd' and whose Tokens count is at least two is followed, at ANY later position in
237
+ the segment list, by a segment whose CommandWord is one of the read-command family.
238
+ The returned value is that later segment's CommandWord, except that a 'sed' segment
239
+ matches only when its second token is '-n' and then returns 'sed -n'. When more than
240
+ one later segment qualifies, the earliest supplies the return value.
241
+
242
+ The regex could not simply be evaluated per segment: '&&' and ';' are segment
243
+ delimiters under spec D2 Piece 1, so no single segment ever contains both sides of
244
+ the chain.
245
+
246
+ A second leg therefore runs first, before the walk: the retained regex constant is
247
+ evaluated against the ScanText of each segment the scanner already reads raw, which
248
+ is where a wrapper's quoted argument collapses a whole nested chain into one segment
249
+ the walk cannot decompose. The two legs are complementary and neither replaces the
250
+ other, so the ordering between them changes only which leg reports a chain that both
251
+ could see, never whether a chain is reported at all.
252
+
253
+ Adjacency is deliberately NOT required. The retained pattern places a lazy '.*?'
254
+ between the cd argument and the delimiter, so 'cd /x && npm test && grep foo bar.txt'
255
+ is denied today; requiring the read segment to follow the cd segment immediately
256
+ would turn that existing denial into an allow, which acceptance criterion 9 forbids.
257
+ This formulation also widens the delimiter set from '&&' and ';' to every segment
258
+ delimiter, so every command line in which a real cd segment precedes a real read
259
+ segment and is denied today is still denied.
260
+
261
+ One narrowing is intended and is stated rather than left implicit: a cd-then-read
262
+ phrase occurring only inside a quoted span or a heredoc body is denied today and
263
+ allows after this change, because the scanner masks that span before the segment list
264
+ is built. That narrowing is the over-match fix this issue exists to deliver.
265
+ .OUTPUTS
266
+ System.String or $null
267
+ #>
268
+ [CmdletBinding()]
269
+ [OutputType([string])]
270
+ param(
271
+ [Parameter(Mandatory = $false)]
272
+ [AllowEmptyString()]
273
+ [AllowNull()]
274
+ [string] $Command
275
+ )
276
+
277
+ if (-not $Command) {
278
+ return $null
279
+ }
280
+
281
+ $segments = @(Read-CommandLineSegment -CommandText $Command)
282
+
283
+ # D12 call-site row for line 105: the retained pattern, evaluated per segment against
284
+ # ScanText. Complementary to the CommandWord walk below rather than a replacement for it.
285
+ # '&&' and ';' delimit segments, so an unquoted chain never puts both halves in one
286
+ # segment and only the walk can see it; a wrapper-led chain sits entirely inside one
287
+ # segment whose command word is the wrapper, and only this leg can see that. Restricting
288
+ # the leg to segments the scanner already scans raw preserves the intended narrowing:
289
+ # echo "cd /x && head f" is masked and still allows.
290
+ foreach ($segment in $segments) {
291
+ if ((Test-CommandLineSegmentRawScan -Segment $segment) -and
292
+ $segment.ScanText -match $script:CdChainedReadCommandPattern) {
293
+ return ($Matches[2] -replace '\s+', ' ')
75
294
  }
76
295
  }
77
296
 
297
+ $seenCd = $false
298
+
299
+ foreach ($segment in $segments) {
300
+ $tokens = @($segment.Tokens)
301
+ $word = [string]$segment.CommandWord
302
+
303
+ if (-not $seenCd) {
304
+ if ($word -eq 'cd' -and $tokens.Count -ge 2) {
305
+ $seenCd = $true
306
+ }
307
+ continue
308
+ }
309
+
310
+ if ($script:CdChainedReadCommandWords -notcontains $word) {
311
+ continue
312
+ }
313
+ if ($word -eq 'sed') {
314
+ if ($tokens.Count -ge 2 -and $tokens[1] -eq '-n') {
315
+ return 'sed -n'
316
+ }
317
+ continue
318
+ }
319
+ return $word
320
+ }
321
+
78
322
  return $null
79
323
  }
80
324
 
@@ -89,11 +333,16 @@ function Get-BashBlockReason {
89
333
  )
90
334
 
91
335
  $pattern = Get-BlockedPatternMatch -Command $Command
92
- if (-not $pattern) {
93
- return $null
336
+ if ($pattern) {
337
+ return "Blocked dangerous command pattern detected: '$pattern'"
338
+ }
339
+
340
+ $readOp = Get-CdChainedReadCommandMatch -Command $Command
341
+ if ($readOp) {
342
+ return "Forbidden Bash pattern: 'cd ... && $readOp' (or ';'-chained). Claude Code's Bash permission engine cannot resolve a file-reading command ($readOp) against Read() rules once a preceding 'cd' has changed the working directory in the same command line - it always requires manual approval, regardless of any Read() or Bash() allow rule, and regardless of whether the path argument is relative or absolute. Rewrite as a single command using an absolute path instead of 'cd'-ing first, e.g. run $readOp directly against the absolute file path, with no leading 'cd'."
94
343
  }
95
344
 
96
- return "Blocked dangerous command pattern detected: '$pattern'"
345
+ return $null
97
346
  }
98
347
 
99
348
  function Get-BashDenyDecision {
@@ -6,10 +6,11 @@
6
6
  Destination-runtime PowerShell facade for the blast-radius library, porting
7
7
  scripts/dev_tools/compute_blast_radius.py (derive_blast_radius,
8
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:
9
+ scripts/dev_tools/_blast_radius_conflicts.py (conflicts). The ports of
10
+ _smallest_path_overlap and _smallest_common, and the mechanically-mergeable
11
+ path exclusion, live in BlastRadiusConflict.psm1. It imports the extraction,
12
+ glob, truth-table, conflict, and validation modules that sit beside it and
13
+ re-exports the five functions the spec PowerShell surface fixes:
13
14
 
14
15
  - Get-PlanPaths port of extract_plan_paths
15
16
  - Get-BlastRadius port of derive_blast_radius
@@ -58,6 +59,7 @@ Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusGlob.psm1')
58
59
  Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusConfig.psm1') -Force -ErrorAction Stop
59
60
  Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusNormalization.psm1') -Force -ErrorAction Stop
60
61
  Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusValidation.psm1') -Force -ErrorAction Stop
62
+ Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusConflict.psm1') -Force -ErrorAction Stop
61
63
 
62
64
  # Feature-folder handling. Every radius contains its own feature folder, and a
63
65
  # caller may pass either a bare folder name or an already-qualified path.
@@ -81,10 +83,6 @@ $script:ConflictModuleOverlap = 'module_overlap'
81
83
  $script:ConflictSharedSurfaceOverlap = 'shared_surface_overlap'
82
84
  $script:ConflictContractDependency = 'contract_dependency'
83
85
 
84
- # Separator used in an overlapping-pair detail string. The pair is ordered
85
- # ordinally before formatting so the detail is identical in both argument orders.
86
- $script:PairDetailSeparator = ' ~ '
87
-
88
86
 
89
87
  # Port of _feature_folder_glob. Accepting an already-qualified path avoids
90
88
  # producing a doubled docs/features/active/docs/features/active/... entry when a
@@ -341,68 +339,6 @@ function Get-BlastRadiusFromObservedPaths {
341
339
  }
342
340
  }
343
341
 
344
- # Port of _smallest_path_overlap. Each overlapping pair is ordered before it is
345
- # recorded, so the minimum is taken over a set that does not depend on argument
346
- # order; that is what makes the reported detail symmetric.
347
- function Get-SmallestPathOverlap {
348
- [CmdletBinding()]
349
- [OutputType([string])]
350
- param(
351
- [Parameter(Mandatory = $true)]
352
- [AllowEmptyCollection()]
353
- [AllowEmptyString()]
354
- [string[]] $PathA,
355
- [Parameter(Mandatory = $true)]
356
- [AllowEmptyCollection()]
357
- [AllowEmptyString()]
358
- [string[]] $PathB
359
- )
360
-
361
- $detail = [System.Collections.Generic.List[string]]::new()
362
- foreach ($entryA in $PathA) {
363
- foreach ($entryB in $PathB) {
364
- if (-not (Test-EntryOverlap -EntryA $entryA -EntryB $entryB)) {
365
- continue
366
- }
367
- $ordered = if ([string]::CompareOrdinal($entryA, $entryB) -le 0) {
368
- @($entryA, $entryB)
369
- } else {
370
- @($entryB, $entryA)
371
- }
372
- $detail.Add($ordered -join $script:PairDetailSeparator)
373
- }
374
- }
375
-
376
- return (Get-OrdinalSmallestEntry -Entry $detail.ToArray())
377
- }
378
-
379
- # Port of _smallest_common. Two empty collections share nothing, so the result is
380
- # $null and the level contributes no reason.
381
- function Get-SmallestCommonEntry {
382
- [CmdletBinding()]
383
- [OutputType([string])]
384
- param(
385
- [Parameter(Mandatory = $true)]
386
- [AllowEmptyCollection()]
387
- [AllowEmptyString()]
388
- [string[]] $Left,
389
- [Parameter(Mandatory = $true)]
390
- [AllowEmptyCollection()]
391
- [AllowEmptyString()]
392
- [string[]] $Right
393
- )
394
-
395
- $rightSet = [System.Collections.Generic.HashSet[string]]::new($Right, [StringComparer]::Ordinal)
396
- $common = [System.Collections.Generic.List[string]]::new()
397
- foreach ($entry in $Left) {
398
- if ($rightSet.Contains($entry)) {
399
- $common.Add($entry)
400
- }
401
- }
402
-
403
- return (Get-OrdinalSmallestEntry -Entry $common.ToArray())
404
- }
405
-
406
342
  function Test-BlastRadiusConflict {
407
343
  <#
408
344
  .SYNOPSIS
@@ -423,9 +359,10 @@ function Test-BlastRadiusConflict {
423
359
  Second radius record.
424
360
 
425
361
  .PARAMETER Config
426
- Parsed config/blast-radius.json. The relation reads no key from it today;
427
- it is validated and kept in the signature because the contract is frozen
428
- for downstream consumers.
362
+ Parsed config/blast-radius.json. The relation reads exactly one key from
363
+ it, mergeable_paths, whose entries are dropped from both radii before the
364
+ path comparison; the mapping is otherwise validated and kept in the
365
+ signature because the contract is frozen for downstream consumers.
429
366
 
430
367
  .OUTPUTS
431
368
  System.Collections.Hashtable. Keys conflict (a boolean) and reasons (an
@@ -461,8 +398,14 @@ function Test-BlastRadiusConflict {
461
398
  $right = ConvertTo-NormalizedBlastRadius -Radius $RadiusB
462
399
 
463
400
  $reason = [System.Collections.Generic.List[hashtable]]::new()
464
- $pathDetail = Get-SmallestPathOverlap -PathA ([string[]]@($left['paths'])) `
465
- -PathB ([string[]]@($right['paths']))
401
+ # The mechanically-mergeable exclusion lives only here: it filters the two
402
+ # collections this comparison reads and rewrites no radius record, so a
403
+ # derived, declared, or observed radius still lists every project file it
404
+ # cited (issue #643).
405
+ $mergeable = [string[]]@(Get-ConfigMergeablePath -Config $Config)
406
+ $pathDetail = Get-SmallestPathOverlap `
407
+ -PathA ([string[]]@(Get-NonMergeablePathEntry -Entry ([string[]]@($left['paths'])) -MergeablePath $mergeable)) `
408
+ -PathB ([string[]]@(Get-NonMergeablePathEntry -Entry ([string[]]@($right['paths'])) -MergeablePath $mergeable))
466
409
  if ($null -ne $pathDetail) {
467
410
  $reason.Add(@{ kind = $script:ConflictPathOverlap; detail = $pathDetail })
468
411
  }