@danmoisan/drm-copilot-mcp 1.0.26 → 1.1.0

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 (63) hide show
  1. package/out/mcp-server.js +542 -42
  2. package/package.json +1 -1
  3. package/resources/claude-customizations/.claude/hooks/check-powershell-test-purity.ps1 +17 -21
  4. package/resources/claude-customizations/.claude/hooks/check-python-test-purity.ps1 +17 -19
  5. package/resources/claude-customizations/.claude/hooks/enforce-checkpoint-monotonic.ps1 +17 -19
  6. package/resources/claude-customizations/.claude/hooks/enforce-completion-consistency.ps1 +18 -19
  7. package/resources/claude-customizations/.claude/hooks/enforce-discovery-artifact-gate.ps1 +18 -19
  8. package/resources/claude-customizations/.claude/hooks/enforce-epic-invocation-origin.ps1 +54 -32
  9. package/resources/claude-customizations/.claude/hooks/enforce-epic-merge-gate.ps1 +172 -24
  10. package/resources/claude-customizations/.claude/hooks/enforce-epic-wave-barrier.ps1 +51 -22
  11. package/resources/claude-customizations/.claude/hooks/enforce-epic-worktree-removal-gate.ps1 +56 -19
  12. package/resources/claude-customizations/.claude/hooks/enforce-evidence-locations.ps1 +71 -28
  13. package/resources/claude-customizations/.claude/hooks/enforce-feature-folder-order.ps1 +68 -23
  14. package/resources/claude-customizations/.claude/hooks/enforce-mermaid-validation.ps1 +402 -0
  15. package/resources/claude-customizations/.claude/hooks/enforce-model-routing-receipt.ps1 +20 -22
  16. package/resources/claude-customizations/.claude/hooks/enforce-orchestration-preimplementation-gate.ps1 +59 -14
  17. package/resources/claude-customizations/.claude/hooks/enforce-parallel-abandon-gate.ps1 +17 -20
  18. package/resources/claude-customizations/.claude/hooks/enforce-parallel-cohort-barrier-helpers.ps1 +278 -0
  19. package/resources/claude-customizations/.claude/hooks/enforce-parallel-cohort-barrier.ps1 +55 -271
  20. package/resources/claude-customizations/.claude/hooks/enforce-parallel-drift-gate.ps1 +57 -22
  21. package/resources/claude-customizations/.claude/hooks/enforce-parallel-worktree-removal-gate.ps1 +56 -19
  22. package/resources/claude-customizations/.claude/hooks/enforce-powershell-batch-budget.ps1 +70 -27
  23. package/resources/claude-customizations/.claude/hooks/enforce-pr-author-skill-helpers.ps1 +228 -0
  24. package/resources/claude-customizations/.claude/hooks/enforce-pr-author-skill.ps1 +68 -225
  25. package/resources/claude-customizations/.claude/hooks/enforce-prd-feature-before-planner.ps1 +161 -34
  26. package/resources/claude-customizations/.claude/hooks/enforce-promotion-mcp-only.ps1 +59 -22
  27. package/resources/claude-customizations/.claude/hooks/enforce-python-batch-budget.ps1 +70 -27
  28. package/resources/claude-customizations/.claude/hooks/validate-bash.ps1 +32 -18
  29. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadius.psm1 +105 -2
  30. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusConfig.psm1 +32 -52
  31. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusExtraction.psm1 +107 -99
  32. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusNormalization.psm1 +295 -0
  33. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusValidation.psm1 +9 -3
  34. package/resources/claude-customizations/.claude/lib/hook-payload/HookPayload.psm1 +494 -0
  35. package/resources/claude-customizations/.claude/lib/mermaid/MermaidGrammar.psm1 +491 -0
  36. package/resources/claude-customizations/.claude/lib/mermaid/MermaidLineScanner.psm1 +488 -0
  37. package/resources/claude-customizations/.claude/lib/mermaid/MermaidMarkdownFences.psm1 +298 -0
  38. package/resources/claude-customizations/.claude/lib/mermaid/MermaidValidation.psm1 +496 -0
  39. package/resources/claude-customizations/.claude/rules/mermaid.md +142 -0
  40. package/resources/claude-customizations/.claude/rules/parallel-orchestration.md +124 -1
  41. package/resources/claude-customizations/.claude/rules/plan-acceptance-gates.md +116 -0
  42. package/resources/claude-customizations/.claude/settings.json +5 -0
  43. package/resources/claude-customizations/.claude/skills/atomic-plan-contract/SKILL.md +15 -0
  44. package/resources/claude-customizations/.claude/skills/evidence-and-timestamp-conventions/SKILL.md +13 -0
  45. package/resources/claude-customizations/.claude/skills/feature-promotion-lifecycle/SKILL.md +6 -0
  46. package/resources/claude-customizations/.claude/skills/mermaid-diagram/SKILL.md +184 -0
  47. package/resources/claude-customizations/.claude/skills/mermaid-diagram/references/c4.md +50 -0
  48. package/resources/claude-customizations/.claude/skills/mermaid-diagram/references/class.md +63 -0
  49. package/resources/claude-customizations/.claude/skills/mermaid-diagram/references/er.md +56 -0
  50. package/resources/claude-customizations/.claude/skills/mermaid-diagram/references/flowchart.md +68 -0
  51. package/resources/claude-customizations/.claude/skills/mermaid-diagram/references/gantt.md +51 -0
  52. package/resources/claude-customizations/.claude/skills/mermaid-diagram/references/other-types.md +82 -0
  53. package/resources/claude-customizations/.claude/skills/mermaid-diagram/references/pie.md +32 -0
  54. package/resources/claude-customizations/.claude/skills/mermaid-diagram/references/sequence.md +63 -0
  55. package/resources/claude-customizations/.claude/skills/mermaid-diagram/references/state.md +49 -0
  56. package/resources/claude-customizations/.claude/skills/parallel-orchestrate/SKILL.md +8 -7
  57. package/resources/claude-customizations/.claude/skills/parallel-plan/SKILL.md +24 -4
  58. package/resources/claude-customizations/config/blast-radius.json +16 -2
  59. package/resources/claude-customizations/pack-manifests/core.json +22 -1
  60. package/resources/codex-and-agents-customizations/.agents/skills/evidence-and-timestamp-conventions/SKILL.md +13 -0
  61. package/resources/codex-and-agents-customizations/.codex/config.toml +1 -1
  62. package/resources/customizations/.github/skills/evidence-and-timestamp-conventions/SKILL.md +13 -0
  63. package/resources/powershell/PoshQC/settings/pester.runsettings.psd1 +31 -1
@@ -6,8 +6,11 @@
6
6
  Destination-runtime PowerShell port of the text-scanning half of
7
7
  scripts/dev_tools/_blast_radius_extraction.py. Normalizes line endings,
8
8
  partitions atomic-plan lines, extracts backtick-delimited inline-code tokens,
9
- classifies those tokens as concrete repository paths or globs, and extracts
10
- contract identifiers from a feature spec's interface sections.
9
+ and classifies those tokens as concrete repository paths or globs.
10
+ Get-ContractIdentifier was relocated to BlastRadiusNormalization.psm1 so this
11
+ module stays inside the 500-line limit (issue #489); the three module-scoped
12
+ heading variables it read travelled with it, because $script: scope is per
13
+ module.
11
14
 
12
15
  The Python module remains the authoritative reference implementation. This
13
16
  module is one half of a two-language mirror; it never imports validator
@@ -53,17 +56,27 @@ $script:PlanTaskPattern = [regex]::new(
53
56
  # on its own line, from producing spurious spans.
54
57
  $script:InlineCodeSpanPattern = [regex]::new('`([^`]+)`')
55
58
 
56
- # Markdown ATX heading pattern used to locate spec interface sections.
57
- $script:HeadingPattern = [regex]::new('^(?<hashes>#{1,6}) (?<title>.+)$')
58
-
59
59
  # Top-level directories of this repository. A token starting with one of these is
60
- # accepted without needing a recognized extension, which admits directory-shaped
61
- # tokens and ** globs.
60
+ # accepted without needing a recognized extension, which admits ** globs naming a
61
+ # subtree. artifacts/ is deliberately absent (issue #489): the process-artifact
62
+ # tree is read by mandate rather than written by a work item, so admitting
63
+ # artifacts/** as a subtree claim made unrelated items contend.
62
64
  $script:KnownTopLevelSegment = @(
63
65
  'scripts/', 'tests/', 'docs/', 'config/', 'schemas/', 'packages/',
64
- 'extensions/', '.claude/', '.codex/', '.github/', '.agents/', 'artifacts/'
66
+ 'extensions/', '.claude/', '.codex/', '.github/', '.agents/'
65
67
  )
66
68
 
69
+ # A file citation may carry a trailing line reference such as :90. The suffix is
70
+ # stripped before the extension test so a line-anchored citation keeps the
71
+ # acceptance its unanchored form has; the token itself is recorded verbatim.
72
+ $script:LineSuffixPattern = [regex]::new(':\d+$')
73
+
74
+ # Documentation-corpus root and the index, counted after that prefix, of the
75
+ # segment that names one feature folder. A glob whose wildcard reaches this
76
+ # segment or any earlier one claims every feature folder in the corpus.
77
+ $script:FeatureCorpusPrefix = 'docs/features/'
78
+ $script:FeatureFolderSegmentIndex = 1
79
+
67
80
  # Fallback acceptance rule: a token shaped <segment>/.../<name>.<ext> counts as a
68
81
  # repository path when its final component carries one of these extensions.
69
82
  $script:RecognizedPathExtension = [System.Collections.Generic.HashSet[string]]::new(
@@ -74,19 +87,11 @@ $script:RecognizedPathExtension = [System.Collections.Generic.HashSet[string]]::
74
87
  ),
75
88
  [StringComparer]::Ordinal)
76
89
 
77
- # A spec section qualifies as an interface section when its heading, or the
78
- # heading of an ancestor section, contains one of these words.
79
- $script:ContractHeadingKeyword = @('API', 'Interface', 'Contract', 'Surface')
80
-
81
90
  # Classification vocabulary for accepted path tokens. Concrete entries take part
82
91
  # in exact-match checks; glob entries cannot and are matched by pattern.
83
92
  $script:PathKindConcrete = 'concrete'
84
93
  $script:PathKindGlob = 'glob'
85
94
 
86
- # Heading depth sentinel standing in for the Python `qualifying_depth is None`
87
- # state: markdown heading levels are 1..6, so 0 can never be a real level.
88
- $script:NoQualifyingHeadingDepth = 0
89
-
90
95
 
91
96
  function ConvertTo-NormalizedLine {
92
97
  <#
@@ -228,6 +233,60 @@ function Get-InlineCodeToken {
228
233
  return @($token.ToArray())
229
234
  }
230
235
 
236
+ function Test-MultipleFeatureFolderSpan {
237
+ <#
238
+ .SYNOPSIS
239
+ Report whether a glob claims more than one documentation feature folder.
240
+
241
+ .DESCRIPTION
242
+ Port of spans_multiple_feature_folders. The documentation corpus is laid
243
+ out as docs/features/<bucket>/<feature-folder>/..., so a glob whose
244
+ wildcard occupies or truncates the feature-folder segment claims every
245
+ feature folder in the corpus. That made two unrelated work items contend
246
+ purely because both wrote documentation (issue #489). A glob carrying a
247
+ complete, wildcard-free feature-folder segment claims one folder and is
248
+ retained.
249
+
250
+ .PARAMETER Token
251
+ A wildcard-bearing token already accepted by the shape rules of
252
+ Get-PathTokenKind.
253
+
254
+ .OUTPUTS
255
+ System.Boolean. True when the token is rooted in the documentation corpus
256
+ and its wildcard reaches the feature-folder segment or any earlier one.
257
+ #>
258
+ [CmdletBinding()]
259
+ [OutputType([bool])]
260
+ param(
261
+ [Parameter(Mandatory = $true)]
262
+ [AllowEmptyString()]
263
+ [string] $Token
264
+ )
265
+
266
+ if (-not $Token.StartsWith($script:FeatureCorpusPrefix,
267
+ [System.StringComparison]::Ordinal)) {
268
+ return $false
269
+ }
270
+
271
+ $segment = @($Token.Substring($script:FeatureCorpusPrefix.Length) -split '/')
272
+
273
+ # A token that stops at or before the feature-folder segment has had that
274
+ # segment truncated away by the wildcard, so it spans the whole corpus.
275
+ if ($segment.Count -le $script:FeatureFolderSegmentIndex) {
276
+ return $true
277
+ }
278
+
279
+ # Every segment up to and including the feature-folder name must be a literal
280
+ # for the claim to resolve to exactly one folder.
281
+ for ($index = 0; $index -le $script:FeatureFolderSegmentIndex; $index++) {
282
+ if ($segment[$index].IndexOf('*') -ge 0) {
283
+ return $true
284
+ }
285
+ }
286
+
287
+ return $false
288
+ }
289
+
231
290
  function Get-PathTokenKind {
232
291
  <#
233
292
  .SYNOPSIS
@@ -287,14 +346,30 @@ function Get-PathTokenKind {
287
346
  }
288
347
 
289
348
  # Read the final component's extension for the fallback acceptance rule; a
290
- # component with no dot (a directory name or **) has no extension.
291
- $finalComponent = $Token.Substring($Token.LastIndexOf('/') + 1)
349
+ # component with no dot (a directory name or **) has no extension. A trailing
350
+ # line reference is stripped first so file.md:90 reads as md rather than as
351
+ # the unrecognized extension md:90.
352
+ $finalComponent = $script:LineSuffixPattern.Replace(
353
+ $Token.Substring($Token.LastIndexOf('/') + 1), '')
292
354
  $extension = ''
293
355
  $dotIndex = $finalComponent.LastIndexOf('.')
294
356
  if ($dotIndex -ge 0) {
295
357
  $extension = $finalComponent.Substring($dotIndex + 1).ToLowerInvariant()
296
358
  }
297
359
 
360
+ $hasExtension = $script:RecognizedPathExtension.Contains($extension)
361
+
362
+ # A wildcard-free token must name a file, not a directory (issue #489). A
363
+ # directory-shaped token such as scripts/dev_tools is a location reference,
364
+ # not a write claim, and admitting it made every item touching anything under
365
+ # that directory contend at the path level.
366
+ if ($Token.IndexOf('*') -lt 0) {
367
+ if ($hasExtension) {
368
+ return $script:PathKindConcrete
369
+ }
370
+ return $null
371
+ }
372
+
298
373
  $hasKnownSegment = $false
299
374
  foreach ($segment in $script:KnownTopLevelSegment) {
300
375
  if ($Token.StartsWith($segment, [System.StringComparison]::Ordinal)) {
@@ -303,18 +378,23 @@ function Get-PathTokenKind {
303
378
  }
304
379
  }
305
380
 
306
- # Failing both shape rules means the token is prose or a non-path expression
307
- # that merely contains a separator, so it is dropped.
308
- if (-not $hasKnownSegment -and -not $script:RecognizedPathExtension.Contains($extension)) {
381
+ # A wildcard-bearing token must still satisfy one of the two documented shape
382
+ # rules; failing both means the token is prose or a non-path expression that
383
+ # merely contains a separator, so it is dropped.
384
+ if (-not $hasKnownSegment -and -not $hasExtension) {
385
+ return $null
386
+ }
387
+
388
+ # A documentation glob spanning the whole feature corpus is a cross-corpus
389
+ # claim rather than a write claim, so it is dropped before it can become a
390
+ # radius entry.
391
+ if (Test-MultipleFeatureFolderSpan -Token $Token) {
309
392
  return $null
310
393
  }
311
394
 
312
395
  # An accepted token carrying a wildcard names a set of files, so it cannot
313
396
  # take part in concrete exact-match comparisons and is recorded as a glob.
314
- if ($Token.IndexOf('*') -ge 0) {
315
- return $script:PathKindGlob
316
- }
317
- return $script:PathKindConcrete
397
+ return $script:PathKindGlob
318
398
  }
319
399
 
320
400
  function Get-PathFromLine {
@@ -407,84 +487,12 @@ function Get-PlanPaths {
407
487
  return @(Get-PathFromLine -Line $allLine.ToArray() -RootSurface $RootSurface)
408
488
  }
409
489
 
410
- function Get-ContractIdentifier {
411
- <#
412
- .SYNOPSIS
413
- Extract contract identifiers from a spec's interface sections.
414
-
415
- .DESCRIPTION
416
- Port of extract_contract_identifiers. Implements the contracts level of
417
- the radius model: exported symbols, schema names, and CLI identifiers
418
- named in inline code inside sections whose heading, or an ancestor
419
- heading, contains API, Interface, Contract, or Surface. Markdown sections
420
- nest, so a heading deeper than the innermost qualifying heading stays
421
- inside that section and inherits its qualification; a heading at or above
422
- that level ends the section and is judged on its own title.
423
-
424
- .PARAMETER SpecText
425
- Full feature spec.md document text; may be empty.
426
-
427
- .OUTPUTS
428
- System.Object[]. Identifiers, deduplicated and ordinally sorted. Tokens
429
- containing a separator are excluded as path references.
430
- #>
431
- [CmdletBinding()]
432
- [OutputType([System.Object[]])]
433
- param(
434
- [Parameter(Mandatory = $true)]
435
- [AllowEmptyString()]
436
- [string] $SpecText
437
- )
438
-
439
- $identifier = [System.Collections.Generic.List[string]]::new()
440
- $qualifyingDepth = $script:NoQualifyingHeadingDepth
441
-
442
- foreach ($line in @(ConvertTo-NormalizedLine -Text $SpecText)) {
443
- $headingMatch = $script:HeadingPattern.Match($line)
444
-
445
- # A heading changes the section context and contributes no identifiers of
446
- # its own, so each heading is handled and the line is then skipped.
447
- if ($headingMatch.Success) {
448
- $headingLevel = $headingMatch.Groups['hashes'].Value.Length
449
- if ($qualifyingDepth -ne $script:NoQualifyingHeadingDepth -and
450
- $headingLevel -gt $qualifyingDepth) {
451
- continue
452
- }
453
-
454
- $headingTitle = $headingMatch.Groups['title'].Value
455
- $qualifyingDepth = $script:NoQualifyingHeadingDepth
456
- foreach ($keyword in $script:ContractHeadingKeyword) {
457
- if ($headingTitle.IndexOf($keyword, [System.StringComparison]::Ordinal) -ge 0) {
458
- $qualifyingDepth = $headingLevel
459
- break
460
- }
461
- }
462
- continue
463
- }
464
-
465
- if ($qualifyingDepth -eq $script:NoQualifyingHeadingDepth) {
466
- continue
467
- }
468
-
469
- # Inside a qualifying section an inline-code token without a separator is
470
- # a contract identifier; a token with one is a path reference and is
471
- # recorded at the paths level instead.
472
- foreach ($token in @(Get-InlineCodeToken -Line $line)) {
473
- if ($token.IndexOf('/') -lt 0) {
474
- $identifier.Add($token)
475
- }
476
- }
477
- }
478
-
479
- return @(Get-OrdinalSortedEntry -Entry $identifier.ToArray())
480
- }
481
-
482
490
  Export-ModuleMember -Function `
483
491
  Get-OrdinalSortedEntry, `
484
492
  ConvertTo-NormalizedLine, `
485
493
  Get-PlanLineScan, `
486
494
  Get-InlineCodeToken, `
495
+ Test-MultipleFeatureFolderSpan, `
487
496
  Get-PathTokenKind, `
488
497
  Get-PathFromLine, `
489
- Get-PlanPaths, `
490
- Get-ContractIdentifier
498
+ Get-PlanPaths
@@ -0,0 +1,295 @@
1
+ <#
2
+ .SYNOPSIS
3
+ Blast-radius normalization: contract extraction, module resolution, and the
4
+ read-by-mandate exclusion.
5
+
6
+ .DESCRIPTION
7
+ Destination-runtime PowerShell port of
8
+ scripts/dev_tools/_blast_radius_normalization.py, plus the two functions
9
+ relocated here so their source modules stay inside the 500-line limit
10
+ (issue #489): Get-ContractIdentifier from BlastRadiusExtraction.psm1 and
11
+ Resolve-BlastRadiusModule from BlastRadiusConfig.psm1.
12
+
13
+ The Python modules remain the authoritative reference implementation. This
14
+ module is one half of a two-language mirror; it never imports validator
15
+ logic. Every function is pure: no filesystem, subprocess, network, or
16
+ wall-clock access, and no input is mutated.
17
+
18
+ Parity notes for maintainers:
19
+ - Get-ContractIdentifier carries the module-scoped variables it reads
20
+ ($script:HeadingPattern, $script:ContractHeadingKeyword, and
21
+ $script:NoQualifyingHeadingDepth). PowerShell scopes $script: per module,
22
+ so a function-only relocation would resolve them to $null at runtime.
23
+ - Test-MandateRead applies exact ordinal equality first, which is the only
24
+ rule that can settle a glob entry, then glob containment for a concrete
25
+ entry only. A glob entry is never tested for containment in another glob,
26
+ matching matches_mandate_read.
27
+ - An empty mandate-read collection excludes nothing, so a truth table with
28
+ no mandate_reads key reproduces pre-change behaviour exactly.
29
+ - Every returned collection is deduplicated and ordinally sorted.
30
+ #>
31
+
32
+ Set-StrictMode -Version Latest
33
+
34
+ Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusExtraction.psm1') -Force
35
+ Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusGlob.psm1') -Force
36
+ Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusConfig.psm1') -Force
37
+
38
+ # Markdown ATX heading pattern used to locate spec interface sections. Relocated
39
+ # with Get-ContractIdentifier: $script: scope is per module, so the variable must
40
+ # travel with its only consumer.
41
+ $script:HeadingPattern = [regex]::new('^(?<hashes>#{1,6}) (?<title>.+)$')
42
+
43
+ # A spec section qualifies as an interface section when its heading, or the
44
+ # heading of an ancestor section, contains one of these words.
45
+ $script:ContractHeadingKeyword = @('API', 'Interface', 'Contract', 'Surface')
46
+
47
+ # Heading depth sentinel standing in for the Python `qualifying_depth is None`
48
+ # state: markdown heading levels are 1..6, so 0 can never be a real level.
49
+ $script:NoQualifyingHeadingDepth = 0
50
+
51
+ # A contract identifier names something callable or referenceable, so it must
52
+ # carry at least one ASCII letter. Punctuation-only tokens such as -> or a bare
53
+ # digit are notation from an interface example rather than a contract, and
54
+ # admitting them made unrelated specs contend (issue #489).
55
+ $script:ContractLetterPattern = [regex]::new('[A-Za-z]')
56
+
57
+
58
+ function Get-ContractIdentifier {
59
+ <#
60
+ .SYNOPSIS
61
+ Extract contract identifiers from a spec's interface sections.
62
+
63
+ .DESCRIPTION
64
+ Port of extract_contract_identifiers. Implements the contracts level of
65
+ the radius model: exported symbols, schema names, and CLI identifiers
66
+ named in inline code inside sections whose heading, or an ancestor
67
+ heading, contains API, Interface, Contract, or Surface. Markdown sections
68
+ nest, so a heading deeper than the innermost qualifying heading stays
69
+ inside that section and inherits its qualification; a heading at or above
70
+ that level ends the section and is judged on its own title.
71
+
72
+ .PARAMETER SpecText
73
+ Full feature spec.md document text; may be empty.
74
+
75
+ .OUTPUTS
76
+ System.Object[]. Identifiers, deduplicated and ordinally sorted. Tokens
77
+ containing a separator are excluded as path references, as are tokens
78
+ carrying no ASCII letter.
79
+ #>
80
+ [CmdletBinding()]
81
+ [OutputType([System.Object[]])]
82
+ param(
83
+ [Parameter(Mandatory = $true)]
84
+ [AllowEmptyString()]
85
+ [string] $SpecText
86
+ )
87
+
88
+ $identifier = [System.Collections.Generic.List[string]]::new()
89
+ $qualifyingDepth = $script:NoQualifyingHeadingDepth
90
+
91
+ foreach ($line in @(ConvertTo-NormalizedLine -Text $SpecText)) {
92
+ $headingMatch = $script:HeadingPattern.Match($line)
93
+
94
+ # A heading changes the section context and contributes no identifiers of
95
+ # its own, so each heading is handled and the line is then skipped.
96
+ if ($headingMatch.Success) {
97
+ $headingLevel = $headingMatch.Groups['hashes'].Value.Length
98
+ if ($qualifyingDepth -ne $script:NoQualifyingHeadingDepth -and
99
+ $headingLevel -gt $qualifyingDepth) {
100
+ continue
101
+ }
102
+
103
+ $headingTitle = $headingMatch.Groups['title'].Value
104
+ $qualifyingDepth = $script:NoQualifyingHeadingDepth
105
+ foreach ($keyword in $script:ContractHeadingKeyword) {
106
+ if ($headingTitle.IndexOf($keyword, [System.StringComparison]::Ordinal) -ge 0) {
107
+ $qualifyingDepth = $headingLevel
108
+ break
109
+ }
110
+ }
111
+ continue
112
+ }
113
+
114
+ if ($qualifyingDepth -eq $script:NoQualifyingHeadingDepth) {
115
+ continue
116
+ }
117
+
118
+ # Inside a qualifying section an inline-code token without a separator is
119
+ # a contract identifier; a token with one is a path reference and is
120
+ # recorded at the paths level instead. A token carrying no ASCII letter
121
+ # is notation, not an identifier, and is dropped (issue #489).
122
+ foreach ($token in @(Get-InlineCodeToken -Line $line)) {
123
+ if ($token.IndexOf('/') -lt 0 -and
124
+ $script:ContractLetterPattern.IsMatch($token)) {
125
+ $identifier.Add($token)
126
+ }
127
+ }
128
+ }
129
+
130
+ return @(Get-OrdinalSortedEntry -Entry $identifier.ToArray())
131
+ }
132
+
133
+ function Resolve-BlastRadiusModule {
134
+ <#
135
+ .SYNOPSIS
136
+ Resolve path entries to the module names of the truth-table map.
137
+
138
+ .DESCRIPTION
139
+ Port of resolve_modules. A module joins the radius as soon as one of its
140
+ globs covers one entry, so the search stops at the first hit per module.
141
+ A path matching no glob resolves to no module.
142
+
143
+ .PARAMETER PathEntry
144
+ Concrete paths and globs of a radius. An empty collection is accepted.
145
+
146
+ .PARAMETER Config
147
+ Parsed config/blast-radius.json.
148
+
149
+ .OUTPUTS
150
+ System.Object[]. Matched module names, deduplicated and ordinally sorted.
151
+ #>
152
+ [CmdletBinding()]
153
+ [OutputType([System.Object[]])]
154
+ param(
155
+ [Parameter(Mandatory = $true)]
156
+ [AllowEmptyCollection()]
157
+ [AllowEmptyString()]
158
+ [string[]] $PathEntry,
159
+ [Parameter(Mandatory = $true)]
160
+ [AllowNull()]
161
+ [object] $Config
162
+ )
163
+
164
+ $matched = [System.Collections.Generic.List[string]]::new()
165
+ foreach ($pair in @(Get-ConfigModuleEntry -Config $Config)) {
166
+ foreach ($pattern in $pair['globs']) {
167
+ $hit = $false
168
+ foreach ($entry in $PathEntry) {
169
+ if (Test-GlobMatch -Pattern $pattern -Candidate $entry) {
170
+ $matched.Add([string]$pair['name'])
171
+ $hit = $true
172
+ break
173
+ }
174
+ }
175
+ if ($hit) {
176
+ break
177
+ }
178
+ }
179
+ }
180
+
181
+ return @(Get-OrdinalSortedEntry -Entry $matched.ToArray())
182
+ }
183
+
184
+
185
+ function Test-MandateRead {
186
+ <#
187
+ .SYNOPSIS
188
+ Report whether one radius entry is a read-by-mandate citation.
189
+
190
+ .DESCRIPTION
191
+ Port of matches_mandate_read. Exact ordinal equality settles both a
192
+ concrete path listed verbatim and a glob entry that repeats a configured
193
+ glob character for character. Glob containment then covers a concrete
194
+ path falling inside a configured subtree pattern such as artifacts/**. A
195
+ glob entry is deliberately never tested for containment in another glob:
196
+ deciding whether one pattern subsumes another is not a comparison this
197
+ repository's glob vocabulary supports, and guessing would silently drop a
198
+ genuine claim.
199
+
200
+ .PARAMETER Entry
201
+ One harvested or extracted radius entry: a concrete repository-relative
202
+ path or a glob pattern.
203
+
204
+ .PARAMETER MandateRead
205
+ Configured mandate-read patterns from Get-ConfigMandateRead. An empty
206
+ collection matches nothing.
207
+
208
+ .OUTPUTS
209
+ System.Boolean. True when the entry must be excluded from contention.
210
+ #>
211
+ [CmdletBinding()]
212
+ [OutputType([bool])]
213
+ param(
214
+ [Parameter(Mandatory = $true)]
215
+ [AllowEmptyString()]
216
+ [string] $Entry,
217
+ [Parameter(Mandatory = $true)]
218
+ [AllowEmptyCollection()]
219
+ [AllowEmptyString()]
220
+ [string[]] $MandateRead
221
+ )
222
+
223
+ foreach ($pattern in $MandateRead) {
224
+ if ([string]::Equals($Entry, $pattern, [System.StringComparison]::Ordinal)) {
225
+ return $true
226
+ }
227
+ }
228
+
229
+ # A glob entry that did not match exactly is left alone; only a concrete path
230
+ # is tested for containment in a configured subtree pattern.
231
+ if (Test-GlobEntry -Entry $Entry) {
232
+ return $false
233
+ }
234
+
235
+ foreach ($pattern in $MandateRead) {
236
+ if (Test-GlobMatch -Pattern $pattern -Candidate $Entry) {
237
+ return $true
238
+ }
239
+ }
240
+
241
+ return $false
242
+ }
243
+
244
+ function Get-NonMandateReadEntry {
245
+ <#
246
+ .SYNOPSIS
247
+ Drop every read-by-mandate citation from a collection of radius entries.
248
+
249
+ .DESCRIPTION
250
+ Port of exclude_mandate_reads. Every agent is instructed to read the
251
+ policy rules, the tier map, and the process artifacts before doing any
252
+ work, so a citation of one of those paths is evidence that the author
253
+ obeyed the reading order rather than evidence that the change will write
254
+ the file. Counting such citations as contention made thematically
255
+ unrelated work items collide (issue #489).
256
+
257
+ .PARAMETER Entry
258
+ Harvested or extracted radius entries. An empty collection is accepted.
259
+
260
+ .PARAMETER MandateRead
261
+ Configured mandate-read patterns from Get-ConfigMandateRead. An empty
262
+ collection excludes nothing, so the returned content equals the input
263
+ content.
264
+
265
+ .OUTPUTS
266
+ System.Object[]. Surviving entries, deduplicated and ordinally sorted.
267
+ #>
268
+ [CmdletBinding()]
269
+ [OutputType([System.Object[]])]
270
+ param(
271
+ [Parameter(Mandatory = $true)]
272
+ [AllowEmptyCollection()]
273
+ [AllowEmptyString()]
274
+ [string[]] $Entry,
275
+ [Parameter(Mandatory = $true)]
276
+ [AllowEmptyCollection()]
277
+ [AllowEmptyString()]
278
+ [string[]] $MandateRead
279
+ )
280
+
281
+ $survivor = [System.Collections.Generic.List[string]]::new()
282
+ foreach ($candidate in $Entry) {
283
+ if (-not (Test-MandateRead -Entry $candidate -MandateRead $MandateRead)) {
284
+ $survivor.Add($candidate)
285
+ }
286
+ }
287
+
288
+ return @(Get-OrdinalSortedEntry -Entry $survivor.ToArray())
289
+ }
290
+
291
+ Export-ModuleMember -Function `
292
+ Get-ContractIdentifier, `
293
+ Resolve-BlastRadiusModule, `
294
+ Test-MandateRead, `
295
+ Get-NonMandateReadEntry
@@ -37,6 +37,7 @@ Set-StrictMode -Version Latest
37
37
  Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusExtraction.psm1') -Force
38
38
  Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusGlob.psm1') -Force
39
39
  Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusConfig.psm1') -Force
40
+ Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusNormalization.psm1') -Force
40
41
 
41
42
  # Finding vocabulary. These strings are contract literals consumed by the
42
43
  # downstream parallel schema and planner features.
@@ -348,9 +349,14 @@ function Test-BlastRadius {
348
349
  # The root-surface set comes from the same -Config value that V1 and V2 use
349
350
  # below to resolve modules and shared surfaces, and from the same reader
350
351
  # Get-BlastRadius calls. That shared source is what keeps a derived radius
351
- # passing V1 and V2 against its own plan (issue #452).
352
- $planPath = [string[]]@(Get-PlanPaths -PlanText $PlanText `
353
- -RootSurface ([string[]]@(Get-ConfigRootSurface -Config $Config)))
352
+ # passing V1 and V2 against its own plan (issue #452). The mandate-read
353
+ # exclusion is applied here for the same reason: the derivation harvest drops
354
+ # those citations, so V1 and V2 must not then demand that the radius cover
355
+ # them (issue #489).
356
+ $planPath = [string[]]@(Get-NonMandateReadEntry -MandateRead (
357
+ [string[]]@(Get-ConfigMandateRead -Config $Config)) -Entry (
358
+ [string[]]@(Get-PlanPaths -PlanText $PlanText `
359
+ -RootSurface ([string[]]@(Get-ConfigRootSurface -Config $Config)))))
354
360
  $planConcrete = [string[]]@(Get-ConcreteEntry -Entry $planPath)
355
361
 
356
362
  $finding = [System.Collections.Generic.List[hashtable]]::new()