@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
@@ -4,8 +4,9 @@
4
4
  spec.md, or user-story.md are not yet present in that same folder.
5
5
 
6
6
  .DESCRIPTION
7
- Invoked by the Claude Code PreToolUse hook on Write or Edit operations. Reads
8
- tool input JSON from the CLAUDE_TOOL_INPUT environment variable. When the
7
+ Invoked by the Claude Code PreToolUse hook on Write or Edit operations. Acquires
8
+ the hook payload through the shared reader and reads file_path from the envelope's
9
+ nested tool_input. When the
9
10
  target file_path matches a feature-folder plan.md path under
10
11
  docs/features/(active|archive)/<folder>/plan.md, the script verifies that
11
12
  each of issue.md, spec.md, and user-story.md exists in the same folder.
@@ -24,6 +25,8 @@
24
25
  [CmdletBinding()]
25
26
  param()
26
27
 
28
+
29
+ Import-Module (Join-Path $PSScriptRoot '../lib/hook-payload/HookPayload.psm1') -Force
27
30
  function Get-FeatureFolderFileExistence {
28
31
  <#
29
32
  .SYNOPSIS
@@ -87,28 +90,34 @@ function Test-IsFeaturePlanPath {
87
90
  function Invoke-FeatureFolderOrderDecision {
88
91
  <#
89
92
  .SYNOPSIS
90
- Parses CLAUDE_TOOL_INPUT and produces an allow-or-block decision.
93
+ Parses the PreToolUse envelope and produces an allow-or-block decision.
91
94
  .PARAMETER ToolInputRaw
92
- Raw JSON string. Empty/null returns allow.
95
+ The raw JSON hook payload acquired by Read-ClaudeHookRawPayload. An envelope
96
+ anomaly fails closed as a deny; a well-formed tool_input carrying no file_path
97
+ remains an allow.
93
98
  #>
94
99
  [CmdletBinding()]
95
100
  [OutputType([System.Collections.Specialized.OrderedDictionary])]
96
101
  param(
102
+ [AllowNull()]
103
+ [AllowEmptyString()]
97
104
  [string] $ToolInputRaw
98
105
  )
99
106
 
100
- if (-not $ToolInputRaw) {
101
- return [ordered]@{ hookSpecificOutput = [ordered]@{ hookEventName = 'PreToolUse'; permissionDecision = 'allow' } }
102
- }
103
-
104
- try {
105
- $toolInput = $ToolInputRaw | ConvertFrom-Json -ErrorAction Stop
106
- }
107
- catch {
108
- throw "enforce-feature-folder-order hook received malformed JSON in CLAUDE_TOOL_INPUT: $_"
107
+ $payload = Resolve-ClaudeHookToolInput -Raw $ToolInputRaw
108
+ if (-not $payload.IsValid) {
109
+ return [ordered]@{
110
+ hookSpecificOutput = [ordered]@{
111
+ hookEventName = 'PreToolUse'
112
+ permissionDecision = 'deny'
113
+ permissionDecisionReason = 'FEATURE_FOLDER_ORDER_BLOCKED: payload anomaly - ' +
114
+ (Get-ClaudeHookPayloadAnomalyReason -Anomaly $payload.Anomaly) +
115
+ '. The gate fails closed on an envelope it cannot read.'
116
+ }
117
+ }
109
118
  }
110
119
 
111
- $filePath = $toolInput.file_path
120
+ $filePath = Get-ClaudeHookToolInputString -ToolInput $payload.Value -Name 'file_path'
112
121
  if (-not $filePath) {
113
122
  return [ordered]@{ hookSpecificOutput = [ordered]@{ hookEventName = 'PreToolUse'; permissionDecision = 'allow' } }
114
123
  }
@@ -134,19 +143,55 @@ function Invoke-FeatureFolderOrderDecision {
134
143
  }
135
144
  }
136
145
 
146
+ function Invoke-FeatureFolderOrderEntryPoint {
147
+ <#
148
+ .SYNOPSIS
149
+ Runs the hook decision and returns the process exit code.
150
+ .DESCRIPTION
151
+ Acquires the payload through the shared reader unless the caller supplies
152
+ one, emits the compact decision JSON, and returns 0. It never returns 1:
153
+ exit 1 is non-blocking for PreToolUse, so every anomaly is already a deny
154
+ decision by the time control reaches here. The function does not call exit;
155
+ the thin tail converts the returned code into a process exit.
156
+ .PARAMETER ToolInputRaw
157
+ Optional pre-acquired payload text. When omitted the ReadPayload seam runs.
158
+ .PARAMETER ReadPayload
159
+ Seam for payload acquisition, so tests can drive the empty-on-all-transports
160
+ case without touching a console.
161
+ .OUTPUTS
162
+ System.Int32
163
+ #>
164
+ [CmdletBinding()]
165
+ [OutputType([int])]
166
+ param(
167
+ [AllowNull()]
168
+ [AllowEmptyString()]
169
+ [string] $ToolInputRaw,
170
+
171
+ [scriptblock] $ReadPayload = { Read-ClaudeHookRawPayload }
172
+ )
173
+
174
+ if (-not $PSBoundParameters.ContainsKey('ToolInputRaw')) {
175
+ $ToolInputRaw = [string](& $ReadPayload)
176
+ }
177
+
178
+ $decision = Invoke-FeatureFolderOrderDecision -ToolInputRaw $ToolInputRaw
179
+ $decision | ConvertTo-Json -Compress -Depth 5 | Write-Output
180
+
181
+ return 0
182
+ }
183
+
137
184
  # Guard allows dot-sourcing in tests without executing the entrypoint.
138
185
  if ($MyInvocation.InvocationName -eq '.') {
139
186
  return
140
187
  }
141
188
 
142
- try {
143
- $decision = Invoke-FeatureFolderOrderDecision -ToolInputRaw $env:CLAUDE_TOOL_INPUT
189
+ # The entry point returns its [int] exit code as the last pipeline element and the
190
+ # decision JSON before it. `exit (<call>)` would capture BOTH into the exit
191
+ # expression and emit nothing, so the decision is written explicitly here first.
192
+ $entryPointResult = @(Invoke-FeatureFolderOrderEntryPoint)
193
+ if ($entryPointResult.Count -gt 1) {
194
+ $entryPointResult[0..($entryPointResult.Count - 2)] | Write-Output
144
195
  }
145
- catch {
146
- Write-Error $_
147
- exit 1
148
- }
149
-
150
- $decision | ConvertTo-Json -Compress -Depth 5 | Write-Output
151
196
 
152
- exit 0
197
+ exit ([int]$entryPointResult[-1])
@@ -0,0 +1,402 @@
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 acquires the hook payload through the shared reader and reads the
8
+ envelope's nested tool_input ('file_path' plus 'content' for Write, or
9
+ 'old_string'/'new_string' for Edit), then 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
+ - missing 'file_path' inside a well-formed tool_input, or a path outside the
25
+ '.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
+ ENVELOPE VERSUS CONTENT (revised by issue #501). The hook now distinguishes two
36
+ kinds of unreadability that the original note conflated. An ENVELOPE-level anomaly
37
+ -- empty payload on every transport, unparseable envelope JSON, or a payload with
38
+ no usable tool_input -- means the PreToolUse contract itself drifted, which is the
39
+ exact defect #501 fixed and which must fail loudly rather than silently allow; the
40
+ hook therefore denies. A CONTENT-level judgement remains permissive, and that half
41
+ of the original note stands unchanged and must not be "fixed" into a hard failure:
42
+ an Edit fragment, a Markdown file with no fence, an absent validation module, and a
43
+ diagram the classifier cannot classify all still allow, because blocking a valid
44
+ diagram is worse than missing an invalid one. The prior blanket allow-on-unparseable
45
+ made the gate inert against the payload the harness actually sends.
46
+
47
+ The extension scope check runs before any content scan, so a write outside the
48
+ Mermaid scope pays only the JSON parse.
49
+
50
+ .NOTES
51
+ Compatible with PowerShell 7+.
52
+ This script must not modify any state; it is a read-only validation gate.
53
+ It emits compact hookSpecificOutput JSON on stdout and exits 0 in every case,
54
+ never a non-zero exit and never the {"decision":"block"} shape.
55
+ It invokes no Python and starts no subprocess.
56
+ #>
57
+ [CmdletBinding()]
58
+ param()
59
+
60
+ Set-StrictMode -Version Latest
61
+
62
+ Import-Module (Join-Path $PSScriptRoot '../lib/hook-payload/HookPayload.psm1') -Force
63
+
64
+ $script:MermaidModulePath = Join-Path -Path $PSScriptRoot -ChildPath '../lib/mermaid/MermaidValidation.psm1'
65
+ $script:MermaidSkillPointer = 'See .claude/skills/mermaid-diagram/SKILL.md.'
66
+ $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.'
67
+
68
+ function Import-MermaidValidationModule {
69
+ <#
70
+ .SYNOPSIS
71
+ Imports the validation module, returning $false when it is absent.
72
+ .DESCRIPTION
73
+ A consumer repository that receives this hook without the library must not be
74
+ bricked, so a missing module fails open rather than throwing.
75
+ #>
76
+ [CmdletBinding()]
77
+ [OutputType([bool])]
78
+ param()
79
+
80
+ if (-not (Test-Path -LiteralPath $script:MermaidModulePath -PathType Leaf)) { return $false }
81
+
82
+ try {
83
+ Import-Module -Name $script:MermaidModulePath -Force -ErrorAction Stop
84
+ } catch {
85
+ return $false
86
+ }
87
+
88
+ return $true
89
+ }
90
+
91
+ function Get-MermaidOnDiskContent {
92
+ <#
93
+ .SYNOPSIS
94
+ Reads the current on-disk content of a target file, or $null when unavailable.
95
+ .DESCRIPTION
96
+ The named wrapper seam for the managed-diagram gate. Pester mocks this function
97
+ rather than the filesystem, so no test needs a temporary file.
98
+ #>
99
+ [CmdletBinding()]
100
+ [OutputType([string])]
101
+ param(
102
+ [Parameter(Mandatory)]
103
+ [AllowEmptyString()]
104
+ [string] $Path
105
+ )
106
+
107
+ if ([string]::IsNullOrWhiteSpace($Path)) { return $null }
108
+ if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return $null }
109
+
110
+ try {
111
+ return [string](Get-Content -LiteralPath $Path -Raw -ErrorAction Stop)
112
+ } catch {
113
+ return $null
114
+ }
115
+ }
116
+
117
+ function Get-MermaidToolInputField {
118
+ <#
119
+ .SYNOPSIS
120
+ Reads one field from the parsed tool input, or $null when it is absent.
121
+ #>
122
+ [CmdletBinding()]
123
+ param(
124
+ [AllowNull()]
125
+ $InputObject,
126
+
127
+ [Parameter(Mandatory)]
128
+ [string] $Name
129
+ )
130
+
131
+ if ($null -eq $InputObject) { return $null }
132
+
133
+ $property = $InputObject.PSObject.Properties[$Name]
134
+ if ($null -eq $property) { return $null }
135
+
136
+ return $property.Value
137
+ }
138
+
139
+ function Test-MermaidDiagramFilePath {
140
+ <#
141
+ .SYNOPSIS
142
+ Returns $true when the path names a standalone Mermaid diagram file.
143
+ #>
144
+ [CmdletBinding()]
145
+ [OutputType([bool])]
146
+ param(
147
+ [Parameter(Mandatory)]
148
+ [AllowEmptyString()]
149
+ [string] $FilePath
150
+ )
151
+
152
+ $normalized = $FilePath -replace '\\', '/'
153
+ return [bool]($normalized -imatch '\.(mmd|mermaid)$')
154
+ }
155
+
156
+ function Test-MermaidMarkdownFilePath {
157
+ <#
158
+ .SYNOPSIS
159
+ Returns $true when the path names a Markdown file that may carry a fence.
160
+ #>
161
+ [CmdletBinding()]
162
+ [OutputType([bool])]
163
+ param(
164
+ [Parameter(Mandatory)]
165
+ [AllowEmptyString()]
166
+ [string] $FilePath
167
+ )
168
+
169
+ $normalized = $FilePath -replace '\\', '/'
170
+ return [bool]($normalized -imatch '\.(md|markdown)$')
171
+ }
172
+
173
+ function Get-MermaidAllowDecision {
174
+ <#
175
+ .SYNOPSIS
176
+ Builds the explicit-allow decision.
177
+ #>
178
+ [CmdletBinding()]
179
+ [OutputType([System.Collections.Specialized.OrderedDictionary])]
180
+ param()
181
+
182
+ return [ordered]@{
183
+ hookSpecificOutput = [ordered]@{
184
+ hookEventName = 'PreToolUse'
185
+ permissionDecision = 'allow'
186
+ }
187
+ }
188
+ }
189
+
190
+ function Get-MermaidDenyDecision {
191
+ <#
192
+ .SYNOPSIS
193
+ Builds a deny decision carrying the supplied token-prefixed reason.
194
+ #>
195
+ [CmdletBinding()]
196
+ [OutputType([System.Collections.Specialized.OrderedDictionary])]
197
+ param(
198
+ [Parameter(Mandatory)]
199
+ [string] $Reason
200
+ )
201
+
202
+ return [ordered]@{
203
+ hookSpecificOutput = [ordered]@{
204
+ hookEventName = 'PreToolUse'
205
+ permissionDecision = 'deny'
206
+ permissionDecisionReason = $Reason
207
+ }
208
+ }
209
+ }
210
+
211
+ function Get-MermaidValidationBlockedReason {
212
+ <#
213
+ .SYNOPSIS
214
+ Formats the syntax-deny reason from a structured validation result.
215
+ .DESCRIPTION
216
+ The reason names the defect class, the line number, and the corrective pointer,
217
+ because a deny a reader cannot act on is indistinguishable from a broken gate.
218
+ #>
219
+ [CmdletBinding()]
220
+ [OutputType([string])]
221
+ param(
222
+ [Parameter(Mandatory)]
223
+ [string] $FilePath,
224
+
225
+ [Parameter(Mandatory)]
226
+ $Result,
227
+
228
+ [AllowEmptyString()]
229
+ [string] $Location = ''
230
+ )
231
+
232
+ $finding = @($Result.Findings)[0]
233
+ $declared = if ([string]::IsNullOrWhiteSpace([string]$Result.DiagramType)) { 'no diagram type' } else { "'$($Result.DiagramType)'" }
234
+ $where = if ([string]::IsNullOrWhiteSpace($Location)) { '' } else { " ($Location)" }
235
+
236
+ return "MERMAID_VALIDATION_BLOCKED: '$FilePath'$where declares $declared and has a Mermaid syntax defect: $($finding.Class) at line $($finding.Line): $($finding.Message). $script:MermaidSkillPointer"
237
+ }
238
+
239
+ function Get-MermaidManagedDiagramBlockedReason {
240
+ <#
241
+ .SYNOPSIS
242
+ Formats the managed-diagram deny reason.
243
+ #>
244
+ [CmdletBinding()]
245
+ [OutputType([string])]
246
+ param(
247
+ [Parameter(Mandatory)]
248
+ [string] $FilePath
249
+ )
250
+
251
+ 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"
252
+ }
253
+
254
+ function Get-MermaidMarkdownBlockDecision {
255
+ <#
256
+ .SYNOPSIS
257
+ Validates every eligible fenced block of a Markdown payload.
258
+ .DESCRIPTION
259
+ Returns a deny decision for the first block carrying a defect, or $null when
260
+ every block is either accepted or skipped. A nested block is documentation
261
+ showing example Mermaid; an opted-out block carries the documented marker.
262
+ #>
263
+ [CmdletBinding()]
264
+ [OutputType([System.Collections.Specialized.OrderedDictionary])]
265
+ param(
266
+ [Parameter(Mandatory)]
267
+ [string] $FilePath,
268
+
269
+ [Parameter(Mandatory)]
270
+ [AllowEmptyString()]
271
+ [string] $Content
272
+ )
273
+
274
+ foreach ($block in @(Get-MermaidFenceBlock -Content $Content)) {
275
+ if ($block.IsNested -or $block.IsOptedOut) { continue }
276
+
277
+ $result = Test-MermaidDiagram -Content $block.Content -LineOffset ($block.BodyStartLine - 1)
278
+ if ($result.Verdict -ne 'Invalid') { continue }
279
+
280
+ $location = "the mermaid fence opening at line $($block.StartLine)"
281
+ return Get-MermaidDenyDecision -Reason (Get-MermaidValidationBlockedReason -FilePath $FilePath -Result $result -Location $location)
282
+ }
283
+
284
+ return $null
285
+ }
286
+
287
+ function Invoke-MermaidValidationDecision {
288
+ <#
289
+ .SYNOPSIS
290
+ Parses the Claude Code tool-input JSON and returns the allow-or-deny decision.
291
+ .DESCRIPTION
292
+ The pure decision function, separated from the thin entry point so Pester
293
+ exercises the logic directly. Returns $null when the call is none of this hook's
294
+ business (out of scope, unparseable, or a Markdown file with no fence), which the
295
+ entry point treats as a silent allow.
296
+ .PARAMETER ToolInputRaw
297
+ The raw JSON hook payload acquired by Read-ClaudeHookRawPayload.
298
+ #>
299
+ [CmdletBinding()]
300
+ [OutputType([System.Collections.Specialized.OrderedDictionary])]
301
+ param(
302
+ [AllowEmptyString()]
303
+ [AllowNull()]
304
+ [string] $ToolInputRaw
305
+ )
306
+
307
+ # Envelope-level anomaly: fail closed. See the ENVELOPE VERSUS CONTENT note in
308
+ # this file's header before changing this back to a silent allow.
309
+ $envelope = Resolve-ClaudeHookToolInput -Raw $ToolInputRaw
310
+ if (-not $envelope.IsValid) {
311
+ return Get-MermaidDenyDecision -Reason (
312
+ 'MERMAID_VALIDATION_BLOCKED: payload anomaly - ' +
313
+ (Get-ClaudeHookPayloadAnomalyReason -Anomaly $envelope.Anomaly) +
314
+ '. The gate fails closed on an envelope it cannot read.')
315
+ }
316
+
317
+ $toolInput = $envelope.Value
318
+
319
+ $filePath = [string](Get-MermaidToolInputField -InputObject $toolInput -Name 'file_path')
320
+ if ([string]::IsNullOrWhiteSpace($filePath)) { return $null }
321
+
322
+ # Scope check first: a write outside the Mermaid scope pays only the JSON parse.
323
+ $isDiagramFile = Test-MermaidDiagramFilePath -FilePath $filePath
324
+ $isMarkdownFile = Test-MermaidMarkdownFilePath -FilePath $filePath
325
+ if (-not ($isDiagramFile -or $isMarkdownFile)) { return $null }
326
+
327
+ if (-not (Import-MermaidValidationModule)) { return $null }
328
+
329
+ # Managed-diagram gate: a property of the target file, so Edit is covered without
330
+ # reconstructing the post-edit content, and the opt-out marker cannot suppress it.
331
+ if ($isDiagramFile) {
332
+ $onDisk = Get-MermaidOnDiskContent -Path $filePath
333
+ if (-not [string]::IsNullOrWhiteSpace([string]$onDisk) -and (Test-MermaidManagedDiagram -Content ([string]$onDisk))) {
334
+ return Get-MermaidDenyDecision -Reason (Get-MermaidManagedDiagramBlockedReason -FilePath $filePath)
335
+ }
336
+ }
337
+
338
+ $content = Get-MermaidToolInputField -InputObject $toolInput -Name 'content'
339
+ if ($null -eq $content) {
340
+ # Edit payload: old_string/new_string is a fragment, not the resulting file, so
341
+ # the syntax check cannot run. The next Write catches a regression.
342
+ return Get-MermaidAllowDecision
343
+ }
344
+
345
+ if ($isDiagramFile) {
346
+ $result = Test-MermaidDiagram -Content ([string]$content)
347
+ if ($result.Verdict -eq 'Invalid') {
348
+ return Get-MermaidDenyDecision -Reason (Get-MermaidValidationBlockedReason -FilePath $filePath -Result $result)
349
+ }
350
+
351
+ return Get-MermaidAllowDecision
352
+ }
353
+
354
+ $blocks = @(Get-MermaidFenceBlock -Content ([string]$content))
355
+ if ($blocks.Count -eq 0) { return $null }
356
+
357
+ $decision = Get-MermaidMarkdownBlockDecision -FilePath $filePath -Content ([string]$content)
358
+ if ($null -ne $decision) { return $decision }
359
+
360
+ return Get-MermaidAllowDecision
361
+ }
362
+
363
+ function Invoke-MermaidValidationEntryPoint {
364
+ <#
365
+ .SYNOPSIS
366
+ Thin entry point: writes the decision JSON to stdout and nothing else.
367
+ .DESCRIPTION
368
+ The function emits only the JSON, and the caller exits 0 unconditionally. An
369
+ entry point that also returned a status code would place that code in the same
370
+ output stream as the JSON, so the caller would consume the JSON instead of
371
+ printing it and the decision would never reach Claude Code.
372
+ .PARAMETER ToolInputRaw
373
+ The raw JSON hook payload acquired by Read-ClaudeHookRawPayload.
374
+ #>
375
+ [CmdletBinding()]
376
+ [OutputType([string])]
377
+ param(
378
+ [AllowEmptyString()]
379
+ [AllowNull()]
380
+ [string] $ToolInputRaw
381
+ )
382
+
383
+ if (-not $PSBoundParameters.ContainsKey('ToolInputRaw')) {
384
+ $ToolInputRaw = Read-ClaudeHookRawPayload
385
+ }
386
+
387
+ $decision = Invoke-MermaidValidationDecision -ToolInputRaw $ToolInputRaw
388
+ if ($null -eq $decision) { return }
389
+
390
+ $decision | ConvertTo-Json -Compress -Depth 5
391
+ }
392
+
393
+ # Guard allows dot-sourcing in tests without executing the entrypoint.
394
+ if ($MyInvocation.InvocationName -eq '.') {
395
+ return
396
+ }
397
+
398
+ Invoke-MermaidValidationEntryPoint -ToolInputRaw (Read-ClaudeHookRawPayload)
399
+
400
+ # Exit 0 on allow and on deny alike: the decision travels in the JSON, never in the
401
+ # exit code.
402
+ exit 0
@@ -5,7 +5,7 @@
5
5
 
6
6
  .DESCRIPTION
7
7
  Invoked by the Claude Code PreToolUse hook on the Agent (Task) tool. Reads
8
- tool input JSON from the CLAUDE_TOOL_INPUT environment variable and the
8
+ tool input JSON from the the envelope's nested tool_input environment variable and the
9
9
  orchestrator checkpoint from artifacts/orchestration/orchestrator-state.json.
10
10
 
11
11
  The hook enforces presence only: it cannot read the delegate's chosen
@@ -32,6 +32,8 @@
32
32
  [CmdletBinding()]
33
33
  param()
34
34
 
35
+
36
+ Import-Module (Join-Path $PSScriptRoot '../lib/hook-payload/HookPayload.psm1') -Force
35
37
  function Get-ModelRoutingCheckpoint {
36
38
  <#
37
39
  .SYNOPSIS
@@ -118,7 +120,7 @@ function Test-ModelRoutingReceiptPresent {
118
120
  function Invoke-ModelRoutingReceiptDecision {
119
121
  <#
120
122
  .SYNOPSIS
121
- Parses CLAUDE_TOOL_INPUT and returns an allow-or-block decision object.
123
+ Parses the envelope's nested tool_input and returns an allow-or-block decision object.
122
124
  #>
123
125
  [CmdletBinding()]
124
126
  [OutputType([System.Collections.Specialized.OrderedDictionary])]
@@ -128,21 +130,23 @@ function Invoke-ModelRoutingReceiptDecision {
128
130
 
129
131
  $allow = [ordered]@{ hookSpecificOutput = [ordered]@{ hookEventName = 'PreToolUse'; permissionDecision = 'allow' } }
130
132
 
131
- # Empty or absent tool input is not a delegation this hook can gate.
132
- if (-not $ToolInputRaw) {
133
- return $allow
134
- }
135
-
136
- # Malformed tool-input JSON is allowed through gracefully; this hook is a
137
- # deterrent, not the authoritative validator.
138
- try {
139
- $toolInput = $ToolInputRaw | ConvertFrom-Json -ErrorAction Stop
140
- }
141
- catch {
142
- return $allow
133
+ # Envelope-level anomalies fail closed (issue #501): an unreadable envelope means
134
+ # the PreToolUse contract drifted, which is exactly the condition that made every
135
+ # gate in this runtime inert, so it must deny rather than pass through.
136
+ $envelope = Resolve-ClaudeHookToolInput -Raw $ToolInputRaw
137
+ if (-not $envelope.IsValid) {
138
+ return [ordered]@{
139
+ hookSpecificOutput = [ordered]@{
140
+ hookEventName = 'PreToolUse'
141
+ permissionDecision = 'deny'
142
+ permissionDecisionReason = 'MODEL_ROUTING_RECEIPT_BLOCKED: payload anomaly - ' +
143
+ (Get-ClaudeHookPayloadAnomalyReason -Anomaly $envelope.Anomaly) +
144
+ '. The gate fails closed on an envelope it cannot read.'
145
+ }
146
+ }
143
147
  }
144
148
 
145
- $subagent = [string]$toolInput.subagent_type
149
+ $subagent = Get-ClaudeHookToolInputString -ToolInput $envelope.Value -Name 'subagent_type'
146
150
 
147
151
  # Only the gated Agent-tool delegates are receipt-checked; any other
148
152
  # subagent_type (including orchestrator) passes through.
@@ -169,13 +173,7 @@ if ($MyInvocation.InvocationName -eq '.') {
169
173
  return
170
174
  }
171
175
 
172
- try {
173
- $decision = Invoke-ModelRoutingReceiptDecision -ToolInputRaw $env:CLAUDE_TOOL_INPUT
174
- }
175
- catch {
176
- Write-Error $_
177
- exit 1
178
- }
176
+ $decision = Invoke-ModelRoutingReceiptDecision -ToolInputRaw (Read-ClaudeHookRawPayload)
179
177
 
180
178
  $decision | ConvertTo-Json -Compress -Depth 5 | Write-Output
181
179