@danmoisan/drm-copilot-mcp 1.1.12 → 1.1.15

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 (38) hide show
  1. package/out/mcp-server.js +30 -78
  2. package/package.json +2 -2
  3. package/resources/claude-customizations/.claude/agents/parallel-planner.md +18 -1
  4. package/resources/claude-customizations/.claude/hooks/enforce-completion-consistency.ps1 +16 -8
  5. package/resources/claude-customizations/.claude/hooks/enforce-orchestration-preimplementation-gate-helpers.ps1 +27 -27
  6. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadius.psm1 +43 -6
  7. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusScheduling.psm1 +486 -0
  8. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusValidation.psm1 +5 -2
  9. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusWriteIntent.psm1 +446 -0
  10. package/resources/claude-customizations/.claude/lib/ci-gate/Invoke-CiGateParser.ps1 +330 -0
  11. package/resources/claude-customizations/.claude/rules/parallel-orchestration.md +150 -2
  12. package/resources/claude-customizations/.claude/rules/plan-acceptance-gates.md +5 -5
  13. package/resources/claude-customizations/.claude/rules/shell.md +8 -7
  14. package/resources/claude-customizations/.claude/skills/cleanup-merged-worktrees/SKILL.md +8 -8
  15. package/resources/claude-customizations/.claude/skills/cleanup-merged-worktrees/scripts/cleanup-worktrees.sh +234 -0
  16. package/resources/claude-customizations/.claude/skills/cleanup-merged-worktrees/scripts/cleanup_worktrees_actions_lib.sh +451 -0
  17. package/resources/claude-customizations/.claude/skills/cleanup-merged-worktrees/scripts/cleanup_worktrees_detached_lib.sh +301 -0
  18. package/resources/claude-customizations/.claude/skills/cleanup-merged-worktrees/scripts/cleanup_worktrees_dirt_lib.sh +495 -0
  19. package/resources/claude-customizations/.claude/skills/cleanup-merged-worktrees/scripts/cleanup_worktrees_enumerate_lib.sh +252 -0
  20. package/resources/claude-customizations/.claude/skills/cleanup-merged-worktrees/scripts/cleanup_worktrees_lib.sh +496 -0
  21. package/resources/claude-customizations/.claude/skills/cleanup-merged-worktrees/scripts/cleanup_worktrees_preserve_eol_lib.sh +212 -0
  22. package/resources/claude-customizations/.claude/skills/cleanup-merged-worktrees/scripts/cleanup_worktrees_preserve_lib.sh +492 -0
  23. package/resources/claude-customizations/.claude/skills/cleanup-merged-worktrees/scripts/cleanup_worktrees_report_records_lib.sh +476 -0
  24. package/resources/claude-customizations/.claude/skills/cleanup-merged-worktrees/scripts/cleanup_worktrees_scan_helper.sh +182 -0
  25. package/resources/claude-customizations/.claude/skills/epic-orchestrate/SKILL.md +1 -1
  26. package/resources/claude-customizations/.claude/skills/epic-plan/SKILL.md +11 -0
  27. package/resources/claude-customizations/.claude/skills/orchestrate/SKILL.md +1 -1
  28. package/resources/claude-customizations/.claude/skills/parallel-add/SKILL.md +32 -17
  29. package/resources/claude-customizations/.claude/skills/parallel-plan/SKILL.md +39 -10
  30. package/resources/claude-customizations/config/blast-radius.json +14 -1
  31. package/resources/claude-customizations/pack-manifests/core.json +13 -0
  32. package/resources/codex-and-agents-customizations/.codex/config.toml +1 -1
  33. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-orchestration-preimplementation-gate-epic-resolution.ps1 +493 -0
  34. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-orchestration-preimplementation-gate-epic-scope.ps1 +189 -0
  35. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-orchestration-preimplementation-gate-helpers.ps1 +27 -27
  36. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-orchestration-preimplementation-gate.ps1 +14 -27
  37. package/resources/codex-and-agents-customizations/pack-manifests/core.json +2 -0
  38. package/resources/powershell/PoshQC/settings/pester.runsettings.psd1 +16 -0
@@ -0,0 +1,486 @@
1
+ <#
2
+ .SYNOPSIS
3
+ Integration-cost scheduling layer over the blast-radius contention relation.
4
+
5
+ .DESCRIPTION
6
+ Destination-runtime PowerShell mirror of
7
+ scripts/dev_tools/_blast_radius_scheduling.py (issue #722). The unchanged
8
+ relation Test-BlastRadiusConflict decides whether two radii contend; this
9
+ module weighs the cost of integrating two concurrent branches against the
10
+ parallelism benefit and records a conflict as an edge only when the cost
11
+ exceeds the configured tolerance. Conflicts within tolerance are returned as
12
+ tolerated overlaps. Each exported function names its Python counterpart.
13
+
14
+ Edge rule: edge iff conflict AND (hard OR tolerance_percent is 0 OR
15
+ cost * 100 > benefit * tolerance_percent). Hard means the reasons include
16
+ shared_surface_overlap or contract_dependency. The recorded reason is the
17
+ first kind of the relation's reason list, so the reason enum is unchanged.
18
+
19
+ Parity notes for maintainers:
20
+ - The Python module is the reference implementation; the committed
21
+ scheduling fixtures bind the two runtimes together.
22
+ - No overlap semantics are defined here. The entry-pair overlap test, the
23
+ mergeable exclusion, and the mergeable matcher are the sibling modules'.
24
+ - An absent (or null) conflict_tolerance key reads as tolerance 0 with unit
25
+ weights and durations, so the edge set equals the detected-conflict set.
26
+ - Test-BlastRadiusConflict lives in the facade BlastRadius.psm1, which
27
+ imports this module, so the caller supplies it as the [scriptblock]
28
+ -Relation; an omitted relation fails fast naming the facade.
29
+ - Every function is pure: no filesystem, subprocess, network, or clock
30
+ access, and no input is mutated.
31
+ CONVENTION: this module fails fast at module scope and imports its siblings with -ErrorAction Stop.
32
+ #>
33
+
34
+ Set-StrictMode -Version Latest
35
+ $ErrorActionPreference = 'Stop'
36
+
37
+ Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusGlob.psm1') -Force -ErrorAction Stop
38
+ Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusConfig.psm1') -Force -ErrorAction Stop
39
+ Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusConflict.psm1') -Force -ErrorAction Stop
40
+ Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusValidation.psm1') -Force -ErrorAction Stop
41
+
42
+ # Truth-table key and the label every reader error carries.
43
+ $script:ToleranceKey = 'conflict_tolerance'
44
+ $script:ToleranceLabel = 'config["conflict_tolerance"]'
45
+
46
+ # Member names of the key and of its two nested maps. Each must be carried
47
+ # exactly, so a misspelled name fails fast instead of reading as absent.
48
+ $script:MemberName = @('tolerance_percent', 'weights', 'band_durations', 'default_band', 'append_only_paths')
49
+ $script:WeightName = @('same_file', 'possible_overlap', 'append_only', 'module')
50
+ $script:BandName = @('C1', 'C2', 'C3', 'C4')
51
+
52
+ # Reason kinds that make a conflict an edge at every tolerance: a shared surface
53
+ # or a contract dependency cannot be reconciled by a merge step.
54
+ $script:HardReasonKind = @('shared_surface_overlap', 'contract_dependency')
55
+
56
+ # The fail-fast message for an omitted relation; the facade defines the relation.
57
+ $script:RelationRequired = '-Relation is required: pass ${function:Test-BlastRadiusConflict} from the facade module BlastRadius.psm1.'
58
+
59
+ # tolerance_percent is a percentage of the benefit.
60
+ $script:Percent = 100
61
+
62
+
63
+ # Reject a boolean or non-integer value, or one below Minimum. Booleans are
64
+ # rejected explicitly because the Python reference rejects them.
65
+ function Get-RequiredInteger {
66
+ [CmdletBinding()]
67
+ [OutputType([long])]
68
+ param(
69
+ [Parameter(Mandatory = $true)]
70
+ [AllowNull()]
71
+ [object] $Value,
72
+ [Parameter(Mandatory = $true)]
73
+ [string] $FieldName,
74
+ [Parameter(Mandatory = $true)]
75
+ [long] $Minimum
76
+ )
77
+
78
+ $isInteger = $Value -is [int] -or $Value -is [long] -or $Value -is [int16] -or $Value -is [byte]
79
+ if ($Value -is [bool] -or -not $isInteger) {
80
+ $actual = if ($null -eq $Value) { 'null' } else { $Value.GetType().Name }
81
+ throw "$FieldName must be an integer, got $actual."
82
+ }
83
+ if ($Value -lt $Minimum) {
84
+ throw "$FieldName must be >= $Minimum, got $Value."
85
+ }
86
+
87
+ return [long]$Value
88
+ }
89
+
90
+ # Require a mapping to carry exactly Names, reporting both directions of
91
+ # mismatch in one error. Names are compared ordinally.
92
+ function Assert-ExactMemberName {
93
+ [CmdletBinding()]
94
+ param(
95
+ [Parameter(Mandatory = $true)]
96
+ [hashtable] $Mapping,
97
+ [Parameter(Mandatory = $true)]
98
+ [string[]] $Name,
99
+ [Parameter(Mandatory = $true)]
100
+ [string] $FieldName
101
+ )
102
+
103
+ $present = @($Mapping.Keys | ForEach-Object { [string]$_ })
104
+ $missing = @($Name | Where-Object { $present -cnotcontains $_ })
105
+ $unknown = @($present | Where-Object { $Name -cnotcontains $_ } | Sort-Object)
106
+ if ($missing.Count -gt 0 -or $unknown.Count -gt 0) {
107
+ throw "$FieldName must carry exactly $($Name -join ', '); missing [$($missing -join ', ')], unknown [$($unknown -join ', ')]."
108
+ }
109
+ }
110
+
111
+ # Read a nested map with exactly Names, each an integer >= 1.
112
+ function Get-RequiredIntegerMap {
113
+ [CmdletBinding()]
114
+ [OutputType([hashtable])]
115
+ param(
116
+ [Parameter(Mandatory = $true)]
117
+ [AllowNull()]
118
+ [object] $Value,
119
+ [Parameter(Mandatory = $true)]
120
+ [string[]] $Name,
121
+ [Parameter(Mandatory = $true)]
122
+ [string] $Member
123
+ )
124
+
125
+ $label = "$script:ToleranceLabel[""$Member""]"
126
+ $mapping = Get-RequiredMapping -Value $Value -FieldName $label
127
+ Assert-ExactMemberName -Mapping $mapping -Name $Name -FieldName $label
128
+ $result = @{}
129
+ foreach ($entry in $Name) {
130
+ $result[$entry] = Get-RequiredInteger -Value $mapping[$entry] -FieldName "$label[""$entry""]" -Minimum 1
131
+ }
132
+ return $result
133
+ }
134
+
135
+ function Get-ConfigConflictTolerance {
136
+ <#
137
+ .SYNOPSIS
138
+ Read and strictly validate the conflict_tolerance key of the truth table.
139
+
140
+ .DESCRIPTION
141
+ Port of config_conflict_tolerance. An absent or null key returns the
142
+ strict semantics: tolerance 0, every weight 1, every band duration 1,
143
+ default_band C1, and no append-only paths.
144
+
145
+ .PARAMETER Config
146
+ Parsed config/blast-radius.json.
147
+
148
+ .OUTPUTS
149
+ System.Collections.Hashtable. Keys tolerance_percent, weights,
150
+ band_durations, default_band, and append_only_paths. Every error message
151
+ names the conflict_tolerance key.
152
+ #>
153
+ [CmdletBinding()]
154
+ [OutputType([hashtable])]
155
+ param(
156
+ [Parameter(Mandatory = $true)]
157
+ [AllowNull()]
158
+ [object] $Config
159
+ )
160
+
161
+ $mapping = Get-RequiredMapping -Value $Config -FieldName 'config'
162
+ $value = if ($mapping.ContainsKey($script:ToleranceKey)) { $mapping[$script:ToleranceKey] } else { $null }
163
+ if ($null -eq $value) {
164
+ $unit = @{}
165
+ foreach ($entry in $script:WeightName) { $unit[$entry] = [long]1 }
166
+ $band = @{}
167
+ foreach ($entry in $script:BandName) { $band[$entry] = [long]1 }
168
+ return @{ tolerance_percent = [long]0; weights = $unit; band_durations = $band; default_band = $script:BandName[0]; append_only_paths = [string[]]@() }
169
+ }
170
+
171
+ $member = Get-RequiredMapping -Value $value -FieldName $script:ToleranceLabel
172
+ Assert-ExactMemberName -Mapping $member -Name $script:MemberName -FieldName $script:ToleranceLabel
173
+ $defaultBand = $member['default_band']
174
+ if ($defaultBand -isnot [string] -or $script:BandName -cnotcontains $defaultBand) {
175
+ throw "$script:ToleranceLabel[""default_band""] must be one of $($script:BandName -join ', ')."
176
+ }
177
+
178
+ return @{
179
+ tolerance_percent = Get-RequiredInteger -Value $member['tolerance_percent'] -FieldName "$script:ToleranceLabel[""tolerance_percent""]" -Minimum 0
180
+ weights = Get-RequiredIntegerMap -Value $member['weights'] -Name $script:WeightName -Member 'weights'
181
+ band_durations = Get-RequiredIntegerMap -Value $member['band_durations'] -Name $script:BandName -Member 'band_durations'
182
+ default_band = [string]$defaultBand
183
+ append_only_paths = [string[]]@(Get-RequiredStringList -Value $member['append_only_paths'] -FieldName "$script:ToleranceLabel[""append_only_paths""]")
184
+ }
185
+ }
186
+
187
+ # Weigh one overlapping path pair: append_only when a concrete entry of the pair
188
+ # is append-only (checked first, so a registry named by both items stays cheap),
189
+ # else same_file for two equal concrete entries, else possible_overlap.
190
+ function Get-PathPairWeight {
191
+ [CmdletBinding()]
192
+ [OutputType([long])]
193
+ param(
194
+ [Parameter(Mandatory = $true)]
195
+ [string] $EntryA,
196
+ [Parameter(Mandatory = $true)]
197
+ [string] $EntryB,
198
+ [Parameter(Mandatory = $true)]
199
+ [hashtable] $Tolerance
200
+ )
201
+
202
+ # Only a concrete entry can name an append-only file.
203
+ $concrete = @(@($EntryA, $EntryB) | Where-Object { -not (Test-GlobEntry -Entry $_) })
204
+ $appendOnly = [string[]]@($Tolerance['append_only_paths'])
205
+ foreach ($entry in $concrete) {
206
+ if (Test-MergeablePath -Entry $entry -MergeablePath $appendOnly) {
207
+ return [long]$Tolerance['weights']['append_only']
208
+ }
209
+ }
210
+ if ($concrete.Count -eq 2 -and [string]::Equals($EntryA, $EntryB, [System.StringComparison]::Ordinal)) {
211
+ return [long]$Tolerance['weights']['same_file']
212
+ }
213
+ return [long]$Tolerance['weights']['possible_overlap']
214
+ }
215
+
216
+ function Get-BlastRadiusPairCost {
217
+ <#
218
+ .SYNOPSIS
219
+ Compute the integer integration cost of running two items concurrently.
220
+
221
+ .DESCRIPTION
222
+ Port of pair_cost. Sums the weight of every overlapping path pair left
223
+ after the mergeable exclusion, then adds the module weight times the
224
+ number of shared modules. Mergeable paths therefore contribute 0.
225
+
226
+ .PARAMETER RadiusA
227
+ First radius record.
228
+
229
+ .PARAMETER RadiusB
230
+ Second radius record.
231
+
232
+ .PARAMETER Config
233
+ Parsed truth table; only mergeable_paths is read, for the exclusion the
234
+ relation uses.
235
+
236
+ .PARAMETER Tolerance
237
+ A value returned by Get-ConfigConflictTolerance.
238
+
239
+ .OUTPUTS
240
+ System.Int64. The cost.
241
+ #>
242
+ [CmdletBinding()]
243
+ [OutputType([long])]
244
+ param(
245
+ [Parameter(Mandatory = $true)]
246
+ [AllowNull()]
247
+ [object] $RadiusA,
248
+ [Parameter(Mandatory = $true)]
249
+ [AllowNull()]
250
+ [object] $RadiusB,
251
+ [Parameter(Mandatory = $true)]
252
+ [AllowNull()]
253
+ [object] $Config,
254
+ [Parameter(Mandatory = $true)]
255
+ [hashtable] $Tolerance
256
+ )
257
+
258
+ $left = ConvertTo-NormalizedBlastRadius -Radius $RadiusA
259
+ $right = ConvertTo-NormalizedBlastRadius -Radius $RadiusB
260
+ $mergeable = [string[]]@(Get-ConfigMergeablePath -Config $Config)
261
+ $pathsA = @(Get-NonMergeablePathEntry -Entry ([string[]]@($left['paths'])) -MergeablePath $mergeable)
262
+ $pathsB = @(Get-NonMergeablePathEntry -Entry ([string[]]@($right['paths'])) -MergeablePath $mergeable)
263
+
264
+ # Enumerate the overlapping pairs with the relation's own primitive, so the
265
+ # cost covers exactly the pairs the relation could report.
266
+ [long]$cost = 0
267
+ foreach ($entryA in $pathsA) {
268
+ foreach ($entryB in $pathsB) {
269
+ if (Test-EntryOverlap -EntryA $entryA -EntryB $entryB) {
270
+ $cost += Get-PathPairWeight -EntryA $entryA -EntryB $entryB -Tolerance $Tolerance
271
+ }
272
+ }
273
+ }
274
+ $sharedModule = @(@($left['modules']) | Where-Object { @($right['modules']) -ccontains $_ })
275
+ return $cost + ([long]$Tolerance['weights']['module'] * $sharedModule.Count)
276
+ }
277
+
278
+ function Get-BlastRadiusPairBenefit {
279
+ <#
280
+ .SYNOPSIS
281
+ Compute the parallelism benefit: the shorter of the two band durations.
282
+
283
+ .DESCRIPTION
284
+ Port of pair_benefit. A missing band reads as default_band.
285
+
286
+ .PARAMETER BandA
287
+ First item's complexity band, or null.
288
+
289
+ .PARAMETER BandB
290
+ Second item's complexity band, or null.
291
+
292
+ .PARAMETER Tolerance
293
+ A value returned by Get-ConfigConflictTolerance.
294
+
295
+ .OUTPUTS
296
+ System.Int64. The benefit. Throws when a band is not a configured band.
297
+ #>
298
+ [CmdletBinding()]
299
+ [OutputType([long])]
300
+ param(
301
+ [AllowNull()]
302
+ [object] $BandA,
303
+ [AllowNull()]
304
+ [object] $BandB,
305
+ [Parameter(Mandatory = $true)]
306
+ [hashtable] $Tolerance
307
+ )
308
+
309
+ $duration = foreach ($band in @($BandA, $BandB)) {
310
+ $resolved = if ($null -eq $band) { $Tolerance['default_band'] } else { $band }
311
+ if ($resolved -isnot [string] -or $script:BandName -cnotcontains $resolved) {
312
+ throw "band '$resolved' must be one of $($script:BandName -join ', ')."
313
+ }
314
+ [long]$Tolerance['band_durations'][$resolved]
315
+ }
316
+ return [long]([System.Math]::Min($duration[0], $duration[1]))
317
+ }
318
+
319
+ function Get-BlastRadiusPairDecision {
320
+ <#
321
+ .SYNOPSIS
322
+ Decide whether one pair is an edge, a tolerated overlap, or neither.
323
+
324
+ .DESCRIPTION
325
+ Port of decide_pair. Reads the tolerance first (so a malformed key fails
326
+ before the relation runs), calls the supplied -Relation exactly once
327
+ with RadiusA first, and applies the edge rule. At tolerance 0 the edge
328
+ flag equals the conflict verdict; cost and benefit are still reported.
329
+
330
+ .PARAMETER RadiusA
331
+ First radius record; passed first to the relation.
332
+
333
+ .PARAMETER RadiusB
334
+ Second radius record.
335
+
336
+ .PARAMETER Config
337
+ Parsed truth table.
338
+
339
+ .PARAMETER BandA
340
+ First item's complexity band, or null for default_band.
341
+
342
+ .PARAMETER BandB
343
+ Second item's complexity band, or null for default_band.
344
+
345
+ .PARAMETER Relation
346
+ The detection relation; pass ${function:Test-BlastRadiusConflict}.
347
+
348
+ .OUTPUTS
349
+ System.Collections.Hashtable. Keys conflict, edge, hard, cost, benefit,
350
+ reason, and reasons. Without a conflict, edge and hard are false, cost
351
+ and benefit are 0, reason is null, and reasons is empty.
352
+ #>
353
+ [CmdletBinding()]
354
+ [OutputType([hashtable])]
355
+ param(
356
+ [Parameter(Mandatory = $true)]
357
+ [AllowNull()]
358
+ [object] $RadiusA,
359
+ [Parameter(Mandatory = $true)]
360
+ [AllowNull()]
361
+ [object] $RadiusB,
362
+ [Parameter(Mandatory = $true)]
363
+ [AllowNull()]
364
+ [object] $Config,
365
+ [AllowNull()]
366
+ [object] $BandA,
367
+ [AllowNull()]
368
+ [object] $BandB,
369
+ [scriptblock] $Relation
370
+ )
371
+
372
+ if ($null -eq $Relation) { throw $script:RelationRequired }
373
+ $tolerance = Get-ConfigConflictTolerance -Config $Config
374
+ $result = & $Relation -RadiusA $RadiusA -RadiusB $RadiusB -Config $Config
375
+ if (-not $result['conflict']) {
376
+ return @{ conflict = $false; edge = $false; hard = $false; cost = [long]0; benefit = [long]0; reason = $null; reasons = [string[]]@() }
377
+ }
378
+
379
+ # Project the reason records to their kinds, which keep canonical order.
380
+ $reasons = [string[]]@($result['reasons'] | ForEach-Object { $_['kind'] })
381
+ $hard = @($reasons | Where-Object { $script:HardReasonKind -ccontains $_ }).Count -gt 0
382
+ $cost = Get-BlastRadiusPairCost -RadiusA $RadiusA -RadiusB $RadiusB -Config $Config -Tolerance $tolerance
383
+ $benefit = Get-BlastRadiusPairBenefit -BandA $BandA -BandB $BandB -Tolerance $tolerance
384
+
385
+ # Tolerance 0 is stated explicitly rather than left to the inequality, so
386
+ # strict identity holds whatever the cost enumeration yields.
387
+ $percent = [long]$tolerance['tolerance_percent']
388
+ $edge = $hard -or $percent -eq 0 -or ($cost * $script:Percent -gt $benefit * $percent)
389
+ return @{ conflict = $true; edge = $edge; hard = $hard; cost = $cost; benefit = $benefit; reason = $reasons[0]; reasons = $reasons }
390
+ }
391
+
392
+ # Validate item keys (integers, booleans rejected, distinct) and return the
393
+ # items as records sorted by key.
394
+ function Get-OrderedSchedulingItem {
395
+ [CmdletBinding()]
396
+ [OutputType([System.Object[]])]
397
+ param(
398
+ [Parameter(Mandatory = $true)]
399
+ [AllowEmptyCollection()]
400
+ [object[]] $Item
401
+ )
402
+
403
+ $seen = [System.Collections.Generic.HashSet[long]]::new()
404
+ $record = foreach ($entry in $Item) {
405
+ $mapping = Get-RequiredMapping -Value $entry -FieldName 'scheduling item'
406
+ $key = $mapping['key']
407
+ $isInteger = $key -is [int] -or $key -is [long] -or $key -is [int16] -or $key -is [byte]
408
+ if ($key -is [bool] -or -not $isInteger) {
409
+ throw "scheduling item key must be an integer: $key."
410
+ }
411
+ if (-not $seen.Add([long]$key)) {
412
+ throw "scheduling item keys must be distinct: $key."
413
+ }
414
+ $band = if ($mapping.ContainsKey('band')) { $mapping['band'] } else { $null }
415
+ @{ key = [long]$key; radius = $mapping['radius']; band = $band }
416
+ }
417
+ return @(@($record) | Where-Object { $null -ne $_ } | Sort-Object -Property { $_['key'] })
418
+ }
419
+
420
+ function Get-BlastRadiusConflictEdge {
421
+ <#
422
+ .SYNOPSIS
423
+ Build the scheduling edges and tolerated overlaps of a set of items.
424
+
425
+ .DESCRIPTION
426
+ Port of schedule_conflict_edges. Visits every unordered pair once in
427
+ ascending key order. A pair whose decision is an edge is recorded as an
428
+ edge; a conflict within tolerance is recorded as a tolerated overlap; a
429
+ pair that does not conflict appears in neither list.
430
+
431
+ .PARAMETER Item
432
+ The run's items: records carrying key (an integer issue number), radius,
433
+ and an optional band.
434
+
435
+ .PARAMETER Config
436
+ Parsed truth table.
437
+
438
+ .PARAMETER Relation
439
+ The detection relation passed to every pair decision; pass
440
+ ${function:Test-BlastRadiusConflict}.
441
+
442
+ .OUTPUTS
443
+ System.Collections.Hashtable. Keys edges (ordered records a, b, reason,
444
+ hard, cost, benefit) and tolerated_overlaps (ordered records a, b,
445
+ reasons, cost, benefit), each sorted by (a, b) with a < b.
446
+ #>
447
+ [CmdletBinding()]
448
+ [OutputType([hashtable])]
449
+ param(
450
+ [Parameter(Mandatory = $true)]
451
+ [AllowEmptyCollection()]
452
+ [object[]] $Item,
453
+ [Parameter(Mandatory = $true)]
454
+ [AllowNull()]
455
+ [object] $Config,
456
+ [scriptblock] $Relation
457
+ )
458
+
459
+ if ($null -eq $Relation) { throw $script:RelationRequired }
460
+ [void](Get-ConfigConflictTolerance -Config $Config)
461
+ $ordered = @(Get-OrderedSchedulingItem -Item $Item)
462
+
463
+ $edge = [System.Collections.Generic.List[object]]::new()
464
+ $tolerated = [System.Collections.Generic.List[object]]::new()
465
+ for ($first = 0; $first -lt $ordered.Count; $first++) {
466
+ for ($second = $first + 1; $second -lt $ordered.Count; $second++) {
467
+ $a = $ordered[$first]
468
+ $b = $ordered[$second]
469
+ $decision = Get-BlastRadiusPairDecision -RadiusA $a['radius'] -RadiusB $b['radius'] -Config $Config -BandA $a['band'] -BandB $b['band'] -Relation $Relation
470
+ if ($decision['edge']) {
471
+ $edge.Add([ordered]@{ a = $a['key']; b = $b['key']; reason = $decision['reason']; hard = $decision['hard']; cost = $decision['cost']; benefit = $decision['benefit'] })
472
+ } elseif ($decision['conflict']) {
473
+ $tolerated.Add([ordered]@{ a = $a['key']; b = $b['key']; reasons = $decision['reasons']; cost = $decision['cost']; benefit = $decision['benefit'] })
474
+ }
475
+ }
476
+ }
477
+
478
+ return @{ edges = @($edge.ToArray()); tolerated_overlaps = @($tolerated.ToArray()) }
479
+ }
480
+
481
+ Export-ModuleMember -Function `
482
+ Get-ConfigConflictTolerance, `
483
+ Get-BlastRadiusPairCost, `
484
+ Get-BlastRadiusPairBenefit, `
485
+ Get-BlastRadiusPairDecision, `
486
+ Get-BlastRadiusConflictEdge
@@ -40,6 +40,7 @@ Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusExtraction.p
40
40
  Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusGlob.psm1') -Force -ErrorAction Stop
41
41
  Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusConfig.psm1') -Force -ErrorAction Stop
42
42
  Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusNormalization.psm1') -Force -ErrorAction Stop
43
+ Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusWriteIntent.psm1') -Force -ErrorAction Stop
43
44
 
44
45
  # Finding vocabulary. These strings are contract literals consumed by the
45
46
  # downstream parallel schema and planner features.
@@ -354,10 +355,12 @@ function Test-BlastRadius {
354
355
  # passing V1 and V2 against its own plan (issue #452). The mandate-read
355
356
  # exclusion is applied here for the same reason: the derivation harvest drops
356
357
  # those citations, so V1 and V2 must not then demand that the radius cover
357
- # them (issue #489).
358
+ # them (issue #489). The plan-side extractor is chosen by the selector
359
+ # Get-BlastRadius also uses, so write-intent mode stays self-consistent
360
+ # (issue #722).
358
361
  $planPath = [string[]]@(Get-NonMandateReadEntry -MandateRead (
359
362
  [string[]]@(Get-ConfigMandateRead -Config $Config)) -Entry (
360
- [string[]]@(Get-PlanPaths -PlanText $PlanText `
363
+ [string[]]@(Get-PlanPathForConfig -PlanText $PlanText -Config $Config `
361
364
  -RootSurface ([string[]]@(Get-ConfigRootSurface -Config $Config)))))
362
365
  $planConcrete = [string[]]@(Get-ConcreteEntry -Entry $planPath)
363
366