@danmoisan/drm-copilot-mcp 1.0.26 → 1.0.27

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 +542 -41
  2. package/package.json +1 -1
  3. package/resources/claude-customizations/.claude/hooks/enforce-epic-merge-gate.ps1 +109 -5
  4. package/resources/claude-customizations/.claude/hooks/enforce-mermaid-validation.ps1 +390 -0
  5. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadius.psm1 +105 -2
  6. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusConfig.psm1 +32 -52
  7. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusExtraction.psm1 +107 -99
  8. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusNormalization.psm1 +295 -0
  9. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusValidation.psm1 +9 -3
  10. package/resources/claude-customizations/.claude/lib/mermaid/MermaidGrammar.psm1 +491 -0
  11. package/resources/claude-customizations/.claude/lib/mermaid/MermaidLineScanner.psm1 +488 -0
  12. package/resources/claude-customizations/.claude/lib/mermaid/MermaidMarkdownFences.psm1 +298 -0
  13. package/resources/claude-customizations/.claude/lib/mermaid/MermaidValidation.psm1 +496 -0
  14. package/resources/claude-customizations/.claude/rules/mermaid.md +142 -0
  15. package/resources/claude-customizations/.claude/rules/parallel-orchestration.md +61 -1
  16. package/resources/claude-customizations/.claude/rules/plan-acceptance-gates.md +116 -0
  17. package/resources/claude-customizations/.claude/settings.json +5 -0
  18. package/resources/claude-customizations/.claude/skills/atomic-plan-contract/SKILL.md +15 -0
  19. package/resources/claude-customizations/.claude/skills/evidence-and-timestamp-conventions/SKILL.md +13 -0
  20. package/resources/claude-customizations/.claude/skills/feature-promotion-lifecycle/SKILL.md +6 -0
  21. package/resources/claude-customizations/.claude/skills/mermaid-diagram/SKILL.md +184 -0
  22. package/resources/claude-customizations/.claude/skills/mermaid-diagram/references/c4.md +50 -0
  23. package/resources/claude-customizations/.claude/skills/mermaid-diagram/references/class.md +63 -0
  24. package/resources/claude-customizations/.claude/skills/mermaid-diagram/references/er.md +56 -0
  25. package/resources/claude-customizations/.claude/skills/mermaid-diagram/references/flowchart.md +68 -0
  26. package/resources/claude-customizations/.claude/skills/mermaid-diagram/references/gantt.md +51 -0
  27. package/resources/claude-customizations/.claude/skills/mermaid-diagram/references/other-types.md +82 -0
  28. package/resources/claude-customizations/.claude/skills/mermaid-diagram/references/pie.md +32 -0
  29. package/resources/claude-customizations/.claude/skills/mermaid-diagram/references/sequence.md +63 -0
  30. package/resources/claude-customizations/.claude/skills/mermaid-diagram/references/state.md +49 -0
  31. package/resources/claude-customizations/.claude/skills/parallel-orchestrate/SKILL.md +8 -7
  32. package/resources/claude-customizations/.claude/skills/parallel-plan/SKILL.md +24 -4
  33. package/resources/claude-customizations/config/blast-radius.json +8 -0
  34. package/resources/claude-customizations/pack-manifests/core.json +19 -1
  35. package/resources/codex-and-agents-customizations/.agents/skills/evidence-and-timestamp-conventions/SKILL.md +13 -0
  36. package/resources/codex-and-agents-customizations/.codex/config.toml +1 -1
  37. package/resources/customizations/.github/skills/evidence-and-timestamp-conventions/SKILL.md +13 -0
  38. package/resources/powershell/PoshQC/settings/pester.runsettings.psd1 +16 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danmoisan/drm-copilot-mcp",
3
- "version": "1.0.26",
3
+ "version": "1.0.27",
4
4
  "description": "Stdio MCP server exposing drm-copilot repo-automation tools.",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",
@@ -5,7 +5,7 @@
5
5
  .DESCRIPTION
6
6
  Invoked by the Claude Code PreToolUse hook on the "Bash" matcher before any Bash
7
7
  command runs. Regex-matches gh pr merge with a --merge flag against
8
- CLAUDE_TOOL_INPUT.command and, when matched, allows the merge only when one of two
8
+ CLAUDE_TOOL_INPUT.command and, when matched, allows the merge only when one of three
9
9
  checkpoint-only conditions holds:
10
10
 
11
11
  1. Child-feature path: artifacts/orchestration/orchestrator-state.json exists,
@@ -14,11 +14,17 @@
14
14
  2. Epic-integration path: artifacts/orchestration/epic-orchestrator-state.json exists,
15
15
  epic_merge_pr.ci_gate.conclusion == "success", and, when the command names an
16
16
  explicit PR number, that number matches epic_merge_pr.pr_number.
17
+ 3. Parallel path: artifacts/orchestration/parallel-orchestrator-state.json exists,
18
+ route_id == "parallel", and the command's explicit PR number matches an items[]
19
+ entry whose merge_status == "ci_green". A parallel run always names an explicit PR
20
+ number (each item merges from its own isolated worktree), so a bare command with no
21
+ PR number cannot satisfy this branch.
17
22
 
18
23
  Otherwise the command is denied with reason EPIC_MERGE_GATE_BLOCKED. A missing or
19
- unreadable checkpoint in either branch fails closed (denies); standalone (non-epic)
20
- orchestration never sets epic_mode or populates epic_merge_pr, so it is structurally
21
- prevented from invoking gh pr merge --merge at all.
24
+ unreadable checkpoint in any branch fails closed (denies); standalone (non-epic,
25
+ non-parallel) orchestration never sets epic_mode, populates epic_merge_pr, or writes a
26
+ parallel checkpoint with route_id == "parallel", so it is structurally prevented from
27
+ invoking gh pr merge --merge at all.
22
28
 
23
29
  Design decision: this gate trusts the on-disk checkpoint rather than shelling out live
24
30
  to gh pr view for a real-time head-SHA check, matching the same non-adversarial,
@@ -35,6 +41,7 @@ param()
35
41
 
36
42
  $script:ChildCheckpointPath = 'artifacts/orchestration/orchestrator-state.json'
37
43
  $script:EpicCheckpointPath = 'artifacts/orchestration/epic-orchestrator-state.json'
44
+ $script:ParallelCheckpointPath = 'artifacts/orchestration/parallel-orchestrator-state.json'
38
45
 
39
46
  function Get-ChildOrchestratorCheckpointContent {
40
47
  <#
@@ -72,6 +79,24 @@ function Get-EpicOrchestratorCheckpointContent {
72
79
  return (Get-Content -LiteralPath $script:EpicCheckpointPath -Raw)
73
80
  }
74
81
 
82
+ function Get-ParallelOrchestratorCheckpointContent {
83
+ <#
84
+ .SYNOPSIS
85
+ Read the raw JSON text of the parallel-orchestrator checkpoint. Tests mock
86
+ this function (read seam).
87
+ .OUTPUTS
88
+ System.String or $null
89
+ #>
90
+ [CmdletBinding()]
91
+ [OutputType([string])]
92
+ param()
93
+
94
+ if (-not (Test-Path -LiteralPath $script:ParallelCheckpointPath -PathType Leaf)) {
95
+ return $null
96
+ }
97
+ return (Get-Content -LiteralPath $script:ParallelCheckpointPath -Raw)
98
+ }
99
+
75
100
  function ConvertFrom-EpicMergeGateJson {
76
101
  <#
77
102
  .SYNOPSIS
@@ -113,9 +138,20 @@ function Get-EpicMergeGateCommandPrNumber {
113
138
  [string] $CommandText
114
139
  )
115
140
 
141
+ # Original form: the PR number appears immediately after "merge"
142
+ # (e.g. "gh pr merge 410 --merge"). Preserved verbatim so epic-path outcomes
143
+ # for the forms the epic path uses are unchanged.
116
144
  if ($CommandText -match '(?i)\bgh\s+pr\s+merge\s+(\d+)\b') {
117
145
  return [int]$Matches[1]
118
146
  }
147
+ # Broadened, additive form: the parallel command places the flag before the
148
+ # number (e.g. "gh pr merge --merge 410"). Once "gh pr merge" is confirmed,
149
+ # capture the first standalone run of digits that is not preceded by "-" or a
150
+ # word character, so a flag token such as "--merge" is not treated as a number
151
+ # and a bare "gh pr merge --merge" still yields $null.
152
+ if ($CommandText -match '(?i)\bgh\s+pr\s+merge\b' -and $CommandText -match '(?<![-\w])(\d+)\b') {
153
+ return [int]$Matches[1]
154
+ }
119
155
  return $null
120
156
  }
121
157
 
@@ -206,6 +242,69 @@ function Test-EpicCheckpointAllowsMerge {
206
242
  return $true
207
243
  }
208
244
 
245
+ function Test-ParallelCheckpointAllowsMerge {
246
+ <#
247
+ .SYNOPSIS
248
+ Decision logic for the parallel-orchestrator checkpoint path (branch 3).
249
+ .PARAMETER Checkpoint
250
+ Parsed parallel-orchestrator checkpoint, or $null when absent/unreadable.
251
+ .PARAMETER CommandPrNumber
252
+ The explicit PR number parsed from the command, or $null when the command
253
+ does not name one. A parallel run always names an explicit PR number because
254
+ each item merges from its own isolated worktree, so a $null value denies.
255
+ .OUTPUTS
256
+ System.Boolean
257
+ #>
258
+ [CmdletBinding()]
259
+ [OutputType([bool])]
260
+ param(
261
+ [AllowNull()]
262
+ $Checkpoint,
263
+
264
+ [AllowNull()]
265
+ [Nullable[int]] $CommandPrNumber
266
+ )
267
+
268
+ if ($null -eq $Checkpoint) {
269
+ return $false
270
+ }
271
+ $props = @($Checkpoint.PSObject.Properties.Name)
272
+ if ($props -notcontains 'route_id' -or ([string]$Checkpoint.route_id) -ne 'parallel') {
273
+ return $false
274
+ }
275
+ # A parallel merge always names an explicit PR number; without one the target
276
+ # item cannot be identified, so fail closed.
277
+ if ($null -eq $CommandPrNumber) {
278
+ return $false
279
+ }
280
+ if ($props -notcontains 'items' -or $null -eq $Checkpoint.items) {
281
+ return $false
282
+ }
283
+
284
+ foreach ($item in @($Checkpoint.items)) {
285
+ if ($null -eq $item) {
286
+ continue
287
+ }
288
+ $itemProps = @($item.PSObject.Properties.Name)
289
+ if ($itemProps -notcontains 'pr_number') {
290
+ continue
291
+ }
292
+ $itemPrNumber = 0
293
+ if (-not [int]::TryParse([string]$item.pr_number, [ref] $itemPrNumber)) {
294
+ continue
295
+ }
296
+ if ($itemPrNumber -ne $CommandPrNumber) {
297
+ continue
298
+ }
299
+ if ($itemProps -notcontains 'merge_status') {
300
+ return $false
301
+ }
302
+ return ([string]$item.merge_status) -eq 'ci_green'
303
+ }
304
+
305
+ return $false
306
+ }
307
+
209
308
  function Get-EpicMergeGateAllowDecision {
210
309
  [CmdletBinding()]
211
310
  [OutputType([System.Collections.Specialized.OrderedDictionary])]
@@ -284,7 +383,12 @@ function Invoke-EpicMergeGateDecision {
284
383
  return Get-EpicMergeGateAllowDecision
285
384
  }
286
385
 
287
- return Get-EpicMergeGateBlockDecision -Reason 'EPIC_MERGE_GATE_BLOCKED: gh pr merge --merge requires either a per-feature checkpoint with epic_mode == true and step9_status == "passed", or an epic checkpoint with epic_merge_pr.ci_gate.conclusion == "success" and a matching pr_number. Neither checkpoint satisfied this gate.'
386
+ $parallelCheckpoint = ConvertFrom-EpicMergeGateJson -Raw (Get-ParallelOrchestratorCheckpointContent)
387
+ if (Test-ParallelCheckpointAllowsMerge -Checkpoint $parallelCheckpoint -CommandPrNumber $commandPrNumber) {
388
+ return Get-EpicMergeGateAllowDecision
389
+ }
390
+
391
+ return Get-EpicMergeGateBlockDecision -Reason 'EPIC_MERGE_GATE_BLOCKED: gh pr merge --merge requires either a per-feature checkpoint with epic_mode == true and step9_status == "passed", an epic checkpoint with epic_merge_pr.ci_gate.conclusion == "success" and a matching pr_number, or a parallel-orchestrator checkpoint with route_id == "parallel" whose target item (matched by pr_number) has merge_status == "ci_green". No checkpoint satisfied this gate.'
288
392
  }
289
393
 
290
394
  # Guard allows dot-sourcing in tests without executing the entrypoint.
@@ -0,0 +1,390 @@
1
+ <#
2
+ .SYNOPSIS
3
+ Pre-tool-use hook for Claude Code that blocks Mermaid diagrams with named structural defects.
4
+
5
+ .DESCRIPTION
6
+ This script is invoked by the Claude Code PreToolUse hook before any Write or Edit
7
+ operation. It reads the tool input from the CLAUDE_TOOL_INPUT environment variable
8
+ (JSON with 'file_path' plus 'content' for Write, or 'old_string'/'new_string' for
9
+ Edit) and applies two independent gates:
10
+
11
+ 1. Syntax gate. On a Write of a '.mmd'/'.mermaid' file, the whole file is one
12
+ diagram. On a Write of a Markdown file, every fenced ```mermaid block is a
13
+ diagram. Each diagram is validated by .claude/lib/mermaid/MermaidValidation.psm1
14
+ and a defect of a checked class produces a deny naming the class and the line.
15
+ 2. Managed-diagram gate. A '.mmd'/'.mermaid' file whose ON-DISK frontmatter carries
16
+ 'id:' is connected to the Mermaid Chart sync workflow and must not be hand
17
+ edited. This is a property of the target file rather than of the payload, so it
18
+ applies to Edit as well as Write, and the opt-out marker never suppresses it.
19
+
20
+ The gate's contract is "rejects the named defect classes", never "proves validity".
21
+ Blocking a valid diagram is worse than missing an invalid one, so the hook declines
22
+ to judge rather than rejecting whenever it cannot classify content confidently:
23
+
24
+ - empty, absent, or unparseable CLAUDE_TOOL_INPUT: allow;
25
+ - missing 'file_path', or a path outside the '.mmd'/'.mermaid'/Markdown scope: allow;
26
+ - the validation module absent from disk: allow;
27
+ - an Edit payload (the syntax check needs the whole file, which an
28
+ old_string/new_string fragment cannot supply): allow;
29
+ - a Markdown file carrying no ```mermaid fence: allow;
30
+ - a ```mermaid fence nested inside another open fence, which is documentation
31
+ showing example Mermaid rather than a diagram: skip that block;
32
+ - a fence immediately preceded by '<!-- mermaid-validator: ignore -->': skip that
33
+ block, and only that block.
34
+
35
+ DELIBERATE DIVERGENCE FROM enforce-evidence-locations.ps1: that hook throws on
36
+ malformed CLAUDE_TOOL_INPUT JSON and its entry point exits 1. This hook allows
37
+ instead. The difference is intentional and must not be "fixed" into a hard failure:
38
+ a content gate that hard-fails on input it cannot parse converts an unparseable
39
+ payload into a blocked write, which is the false-positive failure mode this feature
40
+ exists to avoid. The evidence-location hook gates a path, which is always parseable
41
+ when present; this hook gates content, which is not.
42
+
43
+ The extension scope check runs before any content scan, so a write outside the
44
+ Mermaid scope pays only the JSON parse.
45
+
46
+ .NOTES
47
+ Compatible with PowerShell 7+.
48
+ This script must not modify any state; it is a read-only validation gate.
49
+ It emits compact hookSpecificOutput JSON on stdout and exits 0 in every case,
50
+ never a non-zero exit and never the {"decision":"block"} shape.
51
+ It invokes no Python and starts no subprocess.
52
+ #>
53
+ [CmdletBinding()]
54
+ param()
55
+
56
+ Set-StrictMode -Version Latest
57
+
58
+ $script:MermaidModulePath = Join-Path -Path $PSScriptRoot -ChildPath '../lib/mermaid/MermaidValidation.psm1'
59
+ $script:MermaidSkillPointer = 'See .claude/skills/mermaid-diagram/SKILL.md.'
60
+ $script:MermaidSyncPointer = 'Change it through the Mermaid Chart sync workflow in VS Code (Mermaid Chart extension: Sync Diagram with Mermaid, then Review Mermaid Sync) and pull the synced result instead of hand-editing. See .claude/rules/mermaid.md.'
61
+
62
+ function Import-MermaidValidationModule {
63
+ <#
64
+ .SYNOPSIS
65
+ Imports the validation module, returning $false when it is absent.
66
+ .DESCRIPTION
67
+ A consumer repository that receives this hook without the library must not be
68
+ bricked, so a missing module fails open rather than throwing.
69
+ #>
70
+ [CmdletBinding()]
71
+ [OutputType([bool])]
72
+ param()
73
+
74
+ if (-not (Test-Path -LiteralPath $script:MermaidModulePath -PathType Leaf)) { return $false }
75
+
76
+ try {
77
+ Import-Module -Name $script:MermaidModulePath -Force -ErrorAction Stop
78
+ } catch {
79
+ return $false
80
+ }
81
+
82
+ return $true
83
+ }
84
+
85
+ function Get-MermaidOnDiskContent {
86
+ <#
87
+ .SYNOPSIS
88
+ Reads the current on-disk content of a target file, or $null when unavailable.
89
+ .DESCRIPTION
90
+ The named wrapper seam for the managed-diagram gate. Pester mocks this function
91
+ rather than the filesystem, so no test needs a temporary file.
92
+ #>
93
+ [CmdletBinding()]
94
+ [OutputType([string])]
95
+ param(
96
+ [Parameter(Mandatory)]
97
+ [AllowEmptyString()]
98
+ [string] $Path
99
+ )
100
+
101
+ if ([string]::IsNullOrWhiteSpace($Path)) { return $null }
102
+ if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return $null }
103
+
104
+ try {
105
+ return [string](Get-Content -LiteralPath $Path -Raw -ErrorAction Stop)
106
+ } catch {
107
+ return $null
108
+ }
109
+ }
110
+
111
+ function Get-MermaidToolInputField {
112
+ <#
113
+ .SYNOPSIS
114
+ Reads one field from the parsed tool input, or $null when it is absent.
115
+ #>
116
+ [CmdletBinding()]
117
+ param(
118
+ [AllowNull()]
119
+ $InputObject,
120
+
121
+ [Parameter(Mandatory)]
122
+ [string] $Name
123
+ )
124
+
125
+ if ($null -eq $InputObject) { return $null }
126
+
127
+ $property = $InputObject.PSObject.Properties[$Name]
128
+ if ($null -eq $property) { return $null }
129
+
130
+ return $property.Value
131
+ }
132
+
133
+ function Test-MermaidDiagramFilePath {
134
+ <#
135
+ .SYNOPSIS
136
+ Returns $true when the path names a standalone Mermaid diagram file.
137
+ #>
138
+ [CmdletBinding()]
139
+ [OutputType([bool])]
140
+ param(
141
+ [Parameter(Mandatory)]
142
+ [AllowEmptyString()]
143
+ [string] $FilePath
144
+ )
145
+
146
+ $normalized = $FilePath -replace '\\', '/'
147
+ return [bool]($normalized -imatch '\.(mmd|mermaid)$')
148
+ }
149
+
150
+ function Test-MermaidMarkdownFilePath {
151
+ <#
152
+ .SYNOPSIS
153
+ Returns $true when the path names a Markdown file that may carry a fence.
154
+ #>
155
+ [CmdletBinding()]
156
+ [OutputType([bool])]
157
+ param(
158
+ [Parameter(Mandatory)]
159
+ [AllowEmptyString()]
160
+ [string] $FilePath
161
+ )
162
+
163
+ $normalized = $FilePath -replace '\\', '/'
164
+ return [bool]($normalized -imatch '\.(md|markdown)$')
165
+ }
166
+
167
+ function Get-MermaidAllowDecision {
168
+ <#
169
+ .SYNOPSIS
170
+ Builds the explicit-allow decision.
171
+ #>
172
+ [CmdletBinding()]
173
+ [OutputType([System.Collections.Specialized.OrderedDictionary])]
174
+ param()
175
+
176
+ return [ordered]@{
177
+ hookSpecificOutput = [ordered]@{
178
+ hookEventName = 'PreToolUse'
179
+ permissionDecision = 'allow'
180
+ }
181
+ }
182
+ }
183
+
184
+ function Get-MermaidDenyDecision {
185
+ <#
186
+ .SYNOPSIS
187
+ Builds a deny decision carrying the supplied token-prefixed reason.
188
+ #>
189
+ [CmdletBinding()]
190
+ [OutputType([System.Collections.Specialized.OrderedDictionary])]
191
+ param(
192
+ [Parameter(Mandatory)]
193
+ [string] $Reason
194
+ )
195
+
196
+ return [ordered]@{
197
+ hookSpecificOutput = [ordered]@{
198
+ hookEventName = 'PreToolUse'
199
+ permissionDecision = 'deny'
200
+ permissionDecisionReason = $Reason
201
+ }
202
+ }
203
+ }
204
+
205
+ function Get-MermaidValidationBlockedReason {
206
+ <#
207
+ .SYNOPSIS
208
+ Formats the syntax-deny reason from a structured validation result.
209
+ .DESCRIPTION
210
+ The reason names the defect class, the line number, and the corrective pointer,
211
+ because a deny a reader cannot act on is indistinguishable from a broken gate.
212
+ #>
213
+ [CmdletBinding()]
214
+ [OutputType([string])]
215
+ param(
216
+ [Parameter(Mandatory)]
217
+ [string] $FilePath,
218
+
219
+ [Parameter(Mandatory)]
220
+ $Result,
221
+
222
+ [AllowEmptyString()]
223
+ [string] $Location = ''
224
+ )
225
+
226
+ $finding = @($Result.Findings)[0]
227
+ $declared = if ([string]::IsNullOrWhiteSpace([string]$Result.DiagramType)) { 'no diagram type' } else { "'$($Result.DiagramType)'" }
228
+ $where = if ([string]::IsNullOrWhiteSpace($Location)) { '' } else { " ($Location)" }
229
+
230
+ return "MERMAID_VALIDATION_BLOCKED: '$FilePath'$where declares $declared and has a Mermaid syntax defect: $($finding.Class) at line $($finding.Line): $($finding.Message). $script:MermaidSkillPointer"
231
+ }
232
+
233
+ function Get-MermaidManagedDiagramBlockedReason {
234
+ <#
235
+ .SYNOPSIS
236
+ Formats the managed-diagram deny reason.
237
+ #>
238
+ [CmdletBinding()]
239
+ [OutputType([string])]
240
+ param(
241
+ [Parameter(Mandatory)]
242
+ [string] $FilePath
243
+ )
244
+
245
+ return "MERMAID_MANAGED_DIAGRAM_BLOCKED: '$FilePath' is a Mermaid Chart-managed diagram: its on-disk frontmatter carries an 'id:' marker, so a hand-edit would be overwritten by the next sync. $script:MermaidSyncPointer"
246
+ }
247
+
248
+ function Get-MermaidMarkdownBlockDecision {
249
+ <#
250
+ .SYNOPSIS
251
+ Validates every eligible fenced block of a Markdown payload.
252
+ .DESCRIPTION
253
+ Returns a deny decision for the first block carrying a defect, or $null when
254
+ every block is either accepted or skipped. A nested block is documentation
255
+ showing example Mermaid; an opted-out block carries the documented marker.
256
+ #>
257
+ [CmdletBinding()]
258
+ [OutputType([System.Collections.Specialized.OrderedDictionary])]
259
+ param(
260
+ [Parameter(Mandatory)]
261
+ [string] $FilePath,
262
+
263
+ [Parameter(Mandatory)]
264
+ [AllowEmptyString()]
265
+ [string] $Content
266
+ )
267
+
268
+ foreach ($block in @(Get-MermaidFenceBlock -Content $Content)) {
269
+ if ($block.IsNested -or $block.IsOptedOut) { continue }
270
+
271
+ $result = Test-MermaidDiagram -Content $block.Content -LineOffset ($block.BodyStartLine - 1)
272
+ if ($result.Verdict -ne 'Invalid') { continue }
273
+
274
+ $location = "the mermaid fence opening at line $($block.StartLine)"
275
+ return Get-MermaidDenyDecision -Reason (Get-MermaidValidationBlockedReason -FilePath $FilePath -Result $result -Location $location)
276
+ }
277
+
278
+ return $null
279
+ }
280
+
281
+ function Invoke-MermaidValidationDecision {
282
+ <#
283
+ .SYNOPSIS
284
+ Parses the Claude Code tool-input JSON and returns the allow-or-deny decision.
285
+ .DESCRIPTION
286
+ The pure decision function, separated from the thin entry point so Pester
287
+ exercises the logic directly. Returns $null when the call is none of this hook's
288
+ business (out of scope, unparseable, or a Markdown file with no fence), which the
289
+ entry point treats as a silent allow.
290
+ .PARAMETER ToolInputRaw
291
+ The raw JSON string from $env:CLAUDE_TOOL_INPUT.
292
+ #>
293
+ [CmdletBinding()]
294
+ [OutputType([System.Collections.Specialized.OrderedDictionary])]
295
+ param(
296
+ [AllowEmptyString()]
297
+ [AllowNull()]
298
+ [string] $ToolInputRaw
299
+ )
300
+
301
+ if ([string]::IsNullOrWhiteSpace($ToolInputRaw)) { return $null }
302
+
303
+ try {
304
+ $toolInput = $ToolInputRaw | ConvertFrom-Json -ErrorAction Stop
305
+ } catch {
306
+ # Fail open. See the DELIBERATE DIVERGENCE note in this file's header before
307
+ # changing this to a throw.
308
+ return $null
309
+ }
310
+
311
+ $filePath = [string](Get-MermaidToolInputField -InputObject $toolInput -Name 'file_path')
312
+ if ([string]::IsNullOrWhiteSpace($filePath)) { return $null }
313
+
314
+ # Scope check first: a write outside the Mermaid scope pays only the JSON parse.
315
+ $isDiagramFile = Test-MermaidDiagramFilePath -FilePath $filePath
316
+ $isMarkdownFile = Test-MermaidMarkdownFilePath -FilePath $filePath
317
+ if (-not ($isDiagramFile -or $isMarkdownFile)) { return $null }
318
+
319
+ if (-not (Import-MermaidValidationModule)) { return $null }
320
+
321
+ # Managed-diagram gate: a property of the target file, so Edit is covered without
322
+ # reconstructing the post-edit content, and the opt-out marker cannot suppress it.
323
+ if ($isDiagramFile) {
324
+ $onDisk = Get-MermaidOnDiskContent -Path $filePath
325
+ if (-not [string]::IsNullOrWhiteSpace([string]$onDisk) -and (Test-MermaidManagedDiagram -Content ([string]$onDisk))) {
326
+ return Get-MermaidDenyDecision -Reason (Get-MermaidManagedDiagramBlockedReason -FilePath $filePath)
327
+ }
328
+ }
329
+
330
+ $content = Get-MermaidToolInputField -InputObject $toolInput -Name 'content'
331
+ if ($null -eq $content) {
332
+ # Edit payload: old_string/new_string is a fragment, not the resulting file, so
333
+ # the syntax check cannot run. The next Write catches a regression.
334
+ return Get-MermaidAllowDecision
335
+ }
336
+
337
+ if ($isDiagramFile) {
338
+ $result = Test-MermaidDiagram -Content ([string]$content)
339
+ if ($result.Verdict -eq 'Invalid') {
340
+ return Get-MermaidDenyDecision -Reason (Get-MermaidValidationBlockedReason -FilePath $filePath -Result $result)
341
+ }
342
+
343
+ return Get-MermaidAllowDecision
344
+ }
345
+
346
+ $blocks = @(Get-MermaidFenceBlock -Content ([string]$content))
347
+ if ($blocks.Count -eq 0) { return $null }
348
+
349
+ $decision = Get-MermaidMarkdownBlockDecision -FilePath $filePath -Content ([string]$content)
350
+ if ($null -ne $decision) { return $decision }
351
+
352
+ return Get-MermaidAllowDecision
353
+ }
354
+
355
+ function Invoke-MermaidValidationEntryPoint {
356
+ <#
357
+ .SYNOPSIS
358
+ Thin entry point: writes the decision JSON to stdout and nothing else.
359
+ .DESCRIPTION
360
+ The function emits only the JSON, and the caller exits 0 unconditionally. An
361
+ entry point that also returned a status code would place that code in the same
362
+ output stream as the JSON, so the caller would consume the JSON instead of
363
+ printing it and the decision would never reach Claude Code.
364
+ .PARAMETER ToolInputRaw
365
+ The raw JSON string from $env:CLAUDE_TOOL_INPUT.
366
+ #>
367
+ [CmdletBinding()]
368
+ [OutputType([string])]
369
+ param(
370
+ [AllowEmptyString()]
371
+ [AllowNull()]
372
+ [string] $ToolInputRaw = $env:CLAUDE_TOOL_INPUT
373
+ )
374
+
375
+ $decision = Invoke-MermaidValidationDecision -ToolInputRaw $ToolInputRaw
376
+ if ($null -eq $decision) { return }
377
+
378
+ $decision | ConvertTo-Json -Compress -Depth 5
379
+ }
380
+
381
+ # Guard allows dot-sourcing in tests without executing the entrypoint.
382
+ if ($MyInvocation.InvocationName -eq '.') {
383
+ return
384
+ }
385
+
386
+ Invoke-MermaidValidationEntryPoint -ToolInputRaw $env:CLAUDE_TOOL_INPUT
387
+
388
+ # Exit 0 on allow and on deny alike: the decision travels in the JSON, never in the
389
+ # exit code.
390
+ exit 0