@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
@@ -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
+ the envelope's 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,
@@ -33,8 +39,11 @@
33
39
  [CmdletBinding()]
34
40
  param()
35
41
 
42
+ Import-Module (Join-Path $PSScriptRoot '../lib/hook-payload/HookPayload.psm1') -Force
43
+
36
44
  $script:ChildCheckpointPath = 'artifacts/orchestration/orchestrator-state.json'
37
45
  $script:EpicCheckpointPath = 'artifacts/orchestration/epic-orchestrator-state.json'
46
+ $script:ParallelCheckpointPath = 'artifacts/orchestration/parallel-orchestrator-state.json'
38
47
 
39
48
  function Get-ChildOrchestratorCheckpointContent {
40
49
  <#
@@ -72,6 +81,24 @@ function Get-EpicOrchestratorCheckpointContent {
72
81
  return (Get-Content -LiteralPath $script:EpicCheckpointPath -Raw)
73
82
  }
74
83
 
84
+ function Get-ParallelOrchestratorCheckpointContent {
85
+ <#
86
+ .SYNOPSIS
87
+ Read the raw JSON text of the parallel-orchestrator checkpoint. Tests mock
88
+ this function (read seam).
89
+ .OUTPUTS
90
+ System.String or $null
91
+ #>
92
+ [CmdletBinding()]
93
+ [OutputType([string])]
94
+ param()
95
+
96
+ if (-not (Test-Path -LiteralPath $script:ParallelCheckpointPath -PathType Leaf)) {
97
+ return $null
98
+ }
99
+ return (Get-Content -LiteralPath $script:ParallelCheckpointPath -Raw)
100
+ }
101
+
75
102
  function ConvertFrom-EpicMergeGateJson {
76
103
  <#
77
104
  .SYNOPSIS
@@ -113,9 +140,20 @@ function Get-EpicMergeGateCommandPrNumber {
113
140
  [string] $CommandText
114
141
  )
115
142
 
143
+ # Original form: the PR number appears immediately after "merge"
144
+ # (e.g. "gh pr merge 410 --merge"). Preserved verbatim so epic-path outcomes
145
+ # for the forms the epic path uses are unchanged.
116
146
  if ($CommandText -match '(?i)\bgh\s+pr\s+merge\s+(\d+)\b') {
117
147
  return [int]$Matches[1]
118
148
  }
149
+ # Broadened, additive form: the parallel command places the flag before the
150
+ # number (e.g. "gh pr merge --merge 410"). Once "gh pr merge" is confirmed,
151
+ # capture the first standalone run of digits that is not preceded by "-" or a
152
+ # word character, so a flag token such as "--merge" is not treated as a number
153
+ # and a bare "gh pr merge --merge" still yields $null.
154
+ if ($CommandText -match '(?i)\bgh\s+pr\s+merge\b' -and $CommandText -match '(?<![-\w])(\d+)\b') {
155
+ return [int]$Matches[1]
156
+ }
119
157
  return $null
120
158
  }
121
159
 
@@ -206,6 +244,69 @@ function Test-EpicCheckpointAllowsMerge {
206
244
  return $true
207
245
  }
208
246
 
247
+ function Test-ParallelCheckpointAllowsMerge {
248
+ <#
249
+ .SYNOPSIS
250
+ Decision logic for the parallel-orchestrator checkpoint path (branch 3).
251
+ .PARAMETER Checkpoint
252
+ Parsed parallel-orchestrator checkpoint, or $null when absent/unreadable.
253
+ .PARAMETER CommandPrNumber
254
+ The explicit PR number parsed from the command, or $null when the command
255
+ does not name one. A parallel run always names an explicit PR number because
256
+ each item merges from its own isolated worktree, so a $null value denies.
257
+ .OUTPUTS
258
+ System.Boolean
259
+ #>
260
+ [CmdletBinding()]
261
+ [OutputType([bool])]
262
+ param(
263
+ [AllowNull()]
264
+ $Checkpoint,
265
+
266
+ [AllowNull()]
267
+ [Nullable[int]] $CommandPrNumber
268
+ )
269
+
270
+ if ($null -eq $Checkpoint) {
271
+ return $false
272
+ }
273
+ $props = @($Checkpoint.PSObject.Properties.Name)
274
+ if ($props -notcontains 'route_id' -or ([string]$Checkpoint.route_id) -ne 'parallel') {
275
+ return $false
276
+ }
277
+ # A parallel merge always names an explicit PR number; without one the target
278
+ # item cannot be identified, so fail closed.
279
+ if ($null -eq $CommandPrNumber) {
280
+ return $false
281
+ }
282
+ if ($props -notcontains 'items' -or $null -eq $Checkpoint.items) {
283
+ return $false
284
+ }
285
+
286
+ foreach ($item in @($Checkpoint.items)) {
287
+ if ($null -eq $item) {
288
+ continue
289
+ }
290
+ $itemProps = @($item.PSObject.Properties.Name)
291
+ if ($itemProps -notcontains 'pr_number') {
292
+ continue
293
+ }
294
+ $itemPrNumber = 0
295
+ if (-not [int]::TryParse([string]$item.pr_number, [ref] $itemPrNumber)) {
296
+ continue
297
+ }
298
+ if ($itemPrNumber -ne $CommandPrNumber) {
299
+ continue
300
+ }
301
+ if ($itemProps -notcontains 'merge_status') {
302
+ return $false
303
+ }
304
+ return ([string]$item.merge_status) -eq 'ci_green'
305
+ }
306
+
307
+ return $false
308
+ }
309
+
209
310
  function Get-EpicMergeGateAllowDecision {
210
311
  [CmdletBinding()]
211
312
  [OutputType([System.Collections.Specialized.OrderedDictionary])]
@@ -239,29 +340,34 @@ function Get-EpicMergeGateBlockDecision {
239
340
  function Invoke-EpicMergeGateDecision {
240
341
  <#
241
342
  .SYNOPSIS
242
- Parses CLAUDE_TOOL_INPUT and returns an allow-or-block decision.
343
+ Parses the PreToolUse envelope and returns an allow-or-block decision.
344
+ .DESCRIPTION
345
+ Envelope-level anomalies (empty payload, unparseable JSON, missing or
346
+ malformed tool_input) fail closed as a deny; the legacy flat root shape is
347
+ one such anomaly. Property-level absence of command inside a well-formed
348
+ tool_input remains an allow, because that is this gate's scope filter.
243
349
  .PARAMETER ToolInputRaw
244
- The raw JSON tool payload supplied by Claude Code.
350
+ The raw JSON hook payload acquired by Read-ClaudeHookRawPayload.
245
351
  .OUTPUTS
246
352
  System.Collections.Specialized.OrderedDictionary
247
353
  #>
248
354
  [CmdletBinding()]
249
355
  [OutputType([System.Collections.Specialized.OrderedDictionary])]
250
356
  param(
357
+ [AllowNull()]
358
+ [AllowEmptyString()]
251
359
  [string] $ToolInputRaw
252
360
  )
253
361
 
254
- if (-not $ToolInputRaw) {
255
- return Get-EpicMergeGateAllowDecision
256
- }
257
-
258
- try {
259
- $toolInput = $ToolInputRaw | ConvertFrom-Json -ErrorAction Stop
260
- } catch {
261
- throw "enforce-epic-merge-gate hook received malformed JSON in CLAUDE_TOOL_INPUT: $_"
362
+ $payload = Resolve-ClaudeHookToolInput -Raw $ToolInputRaw
363
+ if (-not $payload.IsValid) {
364
+ return Get-EpicMergeGateBlockDecision -Reason (
365
+ 'EPIC_MERGE_GATE_BLOCKED: payload anomaly - ' +
366
+ (Get-ClaudeHookPayloadAnomalyReason -Anomaly $payload.Anomaly) +
367
+ '. The gate fails closed on an envelope it cannot read.')
262
368
  }
263
369
 
264
- $commandText = $toolInput.command
370
+ $commandText = Get-ClaudeHookToolInputString -ToolInput $payload.Value -Name 'command'
265
371
  if (-not $commandText) {
266
372
  return Get-EpicMergeGateAllowDecision
267
373
  }
@@ -284,7 +390,50 @@ function Invoke-EpicMergeGateDecision {
284
390
  return Get-EpicMergeGateAllowDecision
285
391
  }
286
392
 
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.'
393
+ $parallelCheckpoint = ConvertFrom-EpicMergeGateJson -Raw (Get-ParallelOrchestratorCheckpointContent)
394
+ if (Test-ParallelCheckpointAllowsMerge -Checkpoint $parallelCheckpoint -CommandPrNumber $commandPrNumber) {
395
+ return Get-EpicMergeGateAllowDecision
396
+ }
397
+
398
+ 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.'
399
+ }
400
+
401
+ function Invoke-EpicMergeGateEntryPoint {
402
+ <#
403
+ .SYNOPSIS
404
+ Runs the merge-gate decision and returns the process exit code.
405
+ .DESCRIPTION
406
+ Acquires the payload through the shared reader unless the caller supplies
407
+ one, emits the compact decision JSON, and returns 0. It never returns 1:
408
+ exit 1 is non-blocking for PreToolUse, so every anomaly is already a deny
409
+ decision by the time control reaches here. The function does not call exit;
410
+ the thin tail converts the returned code into a process exit.
411
+ .PARAMETER ToolInputRaw
412
+ Optional pre-acquired payload text. When omitted the ReadPayload seam runs.
413
+ .PARAMETER ReadPayload
414
+ Seam for payload acquisition, so tests can drive the empty-on-all-transports
415
+ case without touching a console.
416
+ .OUTPUTS
417
+ System.Int32
418
+ #>
419
+ [CmdletBinding()]
420
+ [OutputType([int])]
421
+ param(
422
+ [AllowNull()]
423
+ [AllowEmptyString()]
424
+ [string] $ToolInputRaw,
425
+
426
+ [scriptblock] $ReadPayload = { Read-ClaudeHookRawPayload }
427
+ )
428
+
429
+ if (-not $PSBoundParameters.ContainsKey('ToolInputRaw')) {
430
+ $ToolInputRaw = [string](& $ReadPayload)
431
+ }
432
+
433
+ $decision = Invoke-EpicMergeGateDecision -ToolInputRaw $ToolInputRaw
434
+ $decision | ConvertTo-Json -Compress -Depth 5 | Write-Output
435
+
436
+ return 0
288
437
  }
289
438
 
290
439
  # Guard allows dot-sourcing in tests without executing the entrypoint.
@@ -292,13 +441,12 @@ if ($MyInvocation.InvocationName -eq '.') {
292
441
  return
293
442
  }
294
443
 
295
- try {
296
- $decision = Invoke-EpicMergeGateDecision -ToolInputRaw $env:CLAUDE_TOOL_INPUT
297
- } catch {
298
- Write-Error $_
299
- exit 1
444
+ # The entry point returns its [int] exit code as the last pipeline element and the
445
+ # decision JSON before it. `exit (<call>)` would capture BOTH into the exit
446
+ # expression and emit nothing, so the decision is written explicitly here first.
447
+ $entryPointResult = @(Invoke-EpicMergeGateEntryPoint)
448
+ if ($entryPointResult.Count -gt 1) {
449
+ $entryPointResult[0..($entryPointResult.Count - 2)] | Write-Output
300
450
  }
301
451
 
302
- $decision | ConvertTo-Json -Compress -Depth 5 | Write-Output
303
-
304
- exit 0
452
+ exit ([int]$entryPointResult[-1])
@@ -4,7 +4,7 @@
4
4
 
5
5
  .DESCRIPTION
6
6
  Invoked by the Claude Code PreToolUse hook on the "Agent" matcher before any Agent
7
- (Task) call runs. Activates only when CLAUDE_TOOL_INPUT.subagent_type == "orchestrator"
7
+ (Task) call runs. Activates only when the envelope's nested tool_input.subagent_type == "orchestrator"
8
8
  and the serialized prompt contains the epic-mode kickoff marker "Epic mode: true".
9
9
 
10
10
  Resolution and decision procedure:
@@ -33,6 +33,8 @@
33
33
  [CmdletBinding()]
34
34
  param()
35
35
 
36
+
37
+ Import-Module (Join-Path $PSScriptRoot '../lib/hook-payload/HookPayload.psm1') -Force
36
38
  $script:EpicCheckpointPath = 'artifacts/orchestration/epic-orchestrator-state.json'
37
39
  $script:AllowedMergeStatuses = @('merged', 'worktree_removed')
38
40
  $script:EpicModeMarker = 'Epic mode: true'
@@ -232,7 +234,7 @@ function Get-EpicWaveBarrierBlockDecision {
232
234
  function Invoke-EpicWaveBarrierDecision {
233
235
  <#
234
236
  .SYNOPSIS
235
- Parses CLAUDE_TOOL_INPUT and returns an allow-or-block decision.
237
+ Parses the envelope's nested tool_input and returns an allow-or-block decision.
236
238
  .PARAMETER ToolInputRaw
237
239
  The raw JSON tool payload supplied by Claude Code.
238
240
  .OUTPUTS
@@ -244,22 +246,20 @@ function Invoke-EpicWaveBarrierDecision {
244
246
  [string] $ToolInputRaw
245
247
  )
246
248
 
247
- if (-not $ToolInputRaw) {
248
- return Get-EpicWaveBarrierAllowDecision
249
- }
250
-
251
- try {
252
- $toolInput = $ToolInputRaw | ConvertFrom-Json -ErrorAction Stop
253
- } catch {
254
- throw "enforce-epic-wave-barrier hook received malformed JSON in CLAUDE_TOOL_INPUT: $_"
249
+ $envelope = Resolve-ClaudeHookToolInput -Raw $ToolInputRaw
250
+ if (-not $envelope.IsValid) {
251
+ return Get-EpicWaveBarrierBlockDecision -Reason (
252
+ 'EPIC_WAVE_BARRIER_BLOCKED: payload anomaly - ' +
253
+ (Get-ClaudeHookPayloadAnomalyReason -Anomaly $envelope.Anomaly) +
254
+ '. The gate fails closed on an envelope it cannot read.')
255
255
  }
256
256
 
257
- $subagent = $toolInput.subagent_type
257
+ $subagent = Get-ClaudeHookToolInputString -ToolInput $envelope.Value -Name 'subagent_type'
258
258
  if (-not $subagent -or $subagent -ne 'orchestrator') {
259
259
  return Get-EpicWaveBarrierAllowDecision
260
260
  }
261
261
 
262
- $prompt = [string]$toolInput.prompt
262
+ $prompt = Get-ClaudeHookToolInputString -ToolInput $envelope.Value -Name 'prompt'
263
263
  if (-not $prompt -or $prompt -notlike "*$script:EpicModeMarker*") {
264
264
  return Get-EpicWaveBarrierAllowDecision
265
265
  }
@@ -287,18 +287,47 @@ function Invoke-EpicWaveBarrierDecision {
287
287
  return Get-EpicWaveBarrierBlockDecision -Reason "EPIC_WAVE_BARRIER_BLOCKED: '$featureFolder' cannot start until every dependency in its depends_on list is durably confirmed merged or worktree_removed in the epic checkpoint. The checkpoint was unreadable, the feature record was not found, or a dependency is not yet safe."
288
288
  }
289
289
 
290
+ function Invoke-EpicWaveBarrierEntryPoint {
291
+ <#
292
+ .SYNOPSIS
293
+ Runs the hook decision and returns the process exit code.
294
+ .DESCRIPTION
295
+ Acquires the payload through the shared reader unless the caller supplies
296
+ one, emits the compact decision JSON, and returns 0. It never returns 1:
297
+ exit 1 is non-blocking for PreToolUse, so every anomaly is already a deny
298
+ decision by the time control reaches here. The function does not call exit;
299
+ the thin tail converts the returned code into a process exit.
300
+ .PARAMETER ToolInputRaw
301
+ Optional pre-acquired payload text. When omitted the ReadPayload seam runs.
302
+ .PARAMETER ReadPayload
303
+ Seam for payload acquisition, so tests can drive the empty-on-all-transports
304
+ case without touching a console.
305
+ .OUTPUTS
306
+ System.Int32
307
+ #>
308
+ [CmdletBinding()]
309
+ [OutputType([int])]
310
+ param(
311
+ [AllowNull()]
312
+ [AllowEmptyString()]
313
+ [string] $ToolInputRaw,
314
+
315
+ [scriptblock] $ReadPayload = { Read-ClaudeHookRawPayload }
316
+ )
317
+
318
+ if (-not $PSBoundParameters.ContainsKey('ToolInputRaw')) {
319
+ $ToolInputRaw = [string](& $ReadPayload)
320
+ }
321
+
322
+ $decision = Invoke-EpicWaveBarrierDecision -ToolInputRaw $ToolInputRaw
323
+ $decision | ConvertTo-Json -Compress -Depth 5 | Write-Output
324
+
325
+ return 0
326
+ }
327
+
290
328
  # Guard allows dot-sourcing in tests without executing the entrypoint.
291
329
  if ($MyInvocation.InvocationName -eq '.') {
292
330
  return
293
331
  }
294
332
 
295
- try {
296
- $decision = Invoke-EpicWaveBarrierDecision -ToolInputRaw $env:CLAUDE_TOOL_INPUT
297
- } catch {
298
- Write-Error $_
299
- exit 1
300
- }
301
-
302
- $decision | ConvertTo-Json -Compress -Depth 5 | Write-Output
303
-
304
- exit 0
333
+ exit (Invoke-EpicWaveBarrierEntryPoint)
@@ -4,7 +4,7 @@
4
4
 
5
5
  .DESCRIPTION
6
6
  Invoked by the Claude Code PreToolUse hook on the "Bash" matcher before any Bash
7
- command runs. Regex-matches git worktree remove against CLAUDE_TOOL_INPUT.command,
7
+ command runs. Regex-matches git worktree remove against the envelope's tool_input.command,
8
8
  extracts the target worktree path argument, reads
9
9
  artifacts/orchestration/epic-orchestrator-state.json, and finds the features[] record
10
10
  whose worktree_path matches. Allows removal only when that record's merge_status is
@@ -21,6 +21,8 @@
21
21
  [CmdletBinding()]
22
22
  param()
23
23
 
24
+
25
+ Import-Module (Join-Path $PSScriptRoot '../lib/hook-payload/HookPayload.psm1') -Force
24
26
  $script:EpicCheckpointPath = 'artifacts/orchestration/epic-orchestrator-state.json'
25
27
  $script:AllowedMergeStatuses = @('merged', 'worktree_removed')
26
28
 
@@ -169,7 +171,7 @@ function Get-EpicWorktreeGateBlockDecision {
169
171
  function Invoke-EpicWorktreeRemovalGateDecision {
170
172
  <#
171
173
  .SYNOPSIS
172
- Parses CLAUDE_TOOL_INPUT and returns an allow-or-block decision.
174
+ Parses the PreToolUse envelope and returns an allow-or-block decision.
173
175
  .PARAMETER ToolInputRaw
174
176
  The raw JSON tool payload supplied by Claude Code.
175
177
  .OUTPUTS
@@ -181,17 +183,15 @@ function Invoke-EpicWorktreeRemovalGateDecision {
181
183
  [string] $ToolInputRaw
182
184
  )
183
185
 
184
- if (-not $ToolInputRaw) {
185
- return Get-EpicWorktreeGateAllowDecision
186
- }
187
-
188
- try {
189
- $toolInput = $ToolInputRaw | ConvertFrom-Json -ErrorAction Stop
190
- } catch {
191
- throw "enforce-epic-worktree-removal-gate hook received malformed JSON in CLAUDE_TOOL_INPUT: $_"
186
+ $payload = Resolve-ClaudeHookToolInput -Raw $ToolInputRaw
187
+ if (-not $payload.IsValid) {
188
+ return Get-EpicWorktreeGateBlockDecision -Reason (
189
+ 'EPIC_WORKTREE_REMOVAL_BLOCKED: payload anomaly - ' +
190
+ (Get-ClaudeHookPayloadAnomalyReason -Anomaly $payload.Anomaly) +
191
+ '. The gate fails closed on an envelope it cannot read.')
192
192
  }
193
193
 
194
- $commandText = $toolInput.command
194
+ $commandText = Get-ClaudeHookToolInputString -ToolInput $payload.Value -Name 'command'
195
195
  if (-not $commandText) {
196
196
  return Get-EpicWorktreeGateAllowDecision
197
197
  }
@@ -220,18 +220,55 @@ function Invoke-EpicWorktreeRemovalGateDecision {
220
220
  return Get-EpicWorktreeGateBlockDecision -Reason "EPIC_WORKTREE_REMOVAL_BLOCKED: git worktree remove for '$worktreePath' requires a matching epic checkpoint features[] record with merge_status in {merged, worktree_removed}. The checkpoint was unreadable, no matching record was found, or merge_status was not yet safe for removal."
221
221
  }
222
222
 
223
+ function Invoke-EpicWorktreeRemovalGateEntryPoint {
224
+ <#
225
+ .SYNOPSIS
226
+ Runs the hook decision and returns the process exit code.
227
+ .DESCRIPTION
228
+ Acquires the payload through the shared reader unless the caller supplies
229
+ one, emits the compact decision JSON, and returns 0. It never returns 1:
230
+ exit 1 is non-blocking for PreToolUse, so every anomaly is already a deny
231
+ decision by the time control reaches here. The function does not call exit;
232
+ the thin tail converts the returned code into a process exit.
233
+ .PARAMETER ToolInputRaw
234
+ Optional pre-acquired payload text. When omitted the ReadPayload seam runs.
235
+ .PARAMETER ReadPayload
236
+ Seam for payload acquisition, so tests can drive the empty-on-all-transports
237
+ case without touching a console.
238
+ .OUTPUTS
239
+ System.Int32
240
+ #>
241
+ [CmdletBinding()]
242
+ [OutputType([int])]
243
+ param(
244
+ [AllowNull()]
245
+ [AllowEmptyString()]
246
+ [string] $ToolInputRaw,
247
+
248
+ [scriptblock] $ReadPayload = { Read-ClaudeHookRawPayload }
249
+ )
250
+
251
+ if (-not $PSBoundParameters.ContainsKey('ToolInputRaw')) {
252
+ $ToolInputRaw = [string](& $ReadPayload)
253
+ }
254
+
255
+ $decision = Invoke-EpicWorktreeRemovalGateDecision -ToolInputRaw $ToolInputRaw
256
+ $decision | ConvertTo-Json -Compress -Depth 5 | Write-Output
257
+
258
+ return 0
259
+ }
260
+
223
261
  # Guard allows dot-sourcing in tests without executing the entrypoint.
224
262
  if ($MyInvocation.InvocationName -eq '.') {
225
263
  return
226
264
  }
227
265
 
228
- try {
229
- $decision = Invoke-EpicWorktreeRemovalGateDecision -ToolInputRaw $env:CLAUDE_TOOL_INPUT
230
- } catch {
231
- Write-Error $_
232
- exit 1
266
+ # The entry point returns its [int] exit code as the last pipeline element and the
267
+ # decision JSON before it. `exit (<call>)` would capture BOTH into the exit
268
+ # expression and emit nothing, so the decision is written explicitly here first.
269
+ $entryPointResult = @(Invoke-EpicWorktreeRemovalGateEntryPoint)
270
+ if ($entryPointResult.Count -gt 1) {
271
+ $entryPointResult[0..($entryPointResult.Count - 2)] | Write-Output
233
272
  }
234
273
 
235
- $decision | ConvertTo-Json -Compress -Depth 5 | Write-Output
236
-
237
- exit 0
274
+ exit ([int]$entryPointResult[-1])
@@ -4,9 +4,9 @@
4
4
 
5
5
  .DESCRIPTION
6
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 a 'file_path' field) and rejects the operation when the target path is
9
- a non-canonical evidence location.
7
+ operation. It acquires the hook payload through the shared reader and reads
8
+ file_path from the envelope's nested tool_input, then rejects the operation when
9
+ the target path is a non-canonical evidence location.
10
10
 
11
11
  Forbidden path prefixes (case-sensitive, normalized to forward-slash):
12
12
  - artifacts/baselines/
@@ -31,7 +31,9 @@
31
31
  response to stdout with hookSpecificOutput.permissionDecision = 'deny' and exits with
32
32
  code 0 so Claude Code surfaces the reason. For allowed paths, a PreToolUse response
33
33
  with permissionDecision = 'allow' is written to stdout and the script exits 0. On hard
34
- failure (malformed JSON input), the script exits 1.
34
+ failure the script still exits 0 with a deny decision: exit 1 is non-blocking for
35
+ PreToolUse, so an envelope anomaly (empty payload, unparseable JSON, missing or
36
+ malformed tool_input) is emitted as a deny rather than raised.
35
37
 
36
38
  .NOTES
37
39
  Compatible with PowerShell 7+.
@@ -40,6 +42,8 @@
40
42
  [CmdletBinding()]
41
43
  param()
42
44
 
45
+ Import-Module (Join-Path $PSScriptRoot '../lib/hook-payload/HookPayload.psm1') -Force
46
+
43
47
  function Test-EvidenceLocationForbidden {
44
48
  <#
45
49
  .SYNOPSIS
@@ -104,32 +108,55 @@ function Get-EvidenceLocationBlockDecision {
104
108
  }
105
109
  }
106
110
 
111
+ function Get-EvidenceLocationAnomalyDecision {
112
+ <#
113
+ .SYNOPSIS
114
+ Build the fail-closed deny decision for an unreadable PreToolUse envelope.
115
+ .PARAMETER Anomaly
116
+ The anomaly code reported by the shared payload reader.
117
+ #>
118
+ [CmdletBinding()]
119
+ [OutputType([System.Collections.Specialized.OrderedDictionary])]
120
+ param(
121
+ [AllowNull()]
122
+ [AllowEmptyString()]
123
+ [string] $Anomaly
124
+ )
125
+
126
+ return [ordered]@{
127
+ hookSpecificOutput = [ordered]@{
128
+ hookEventName = 'PreToolUse'
129
+ permissionDecision = 'deny'
130
+ permissionDecisionReason = 'EVIDENCE_LOCATION_BLOCKED: payload anomaly - ' +
131
+ (Get-ClaudeHookPayloadAnomalyReason -Anomaly $Anomaly) +
132
+ '. The gate fails closed on an envelope it cannot read.'
133
+ }
134
+ }
135
+ }
136
+
107
137
  function Invoke-EvidenceLocationDecision {
108
138
  <#
109
139
  .SYNOPSIS
110
140
  Parses the Claude Code tool-input JSON and returns an allow-or-block decision.
111
141
  .PARAMETER ToolInputRaw
112
- The raw JSON string from $env:CLAUDE_TOOL_INPUT. An empty or null value
113
- results in an allow decision (non-file tool calls have no file_path).
142
+ The raw JSON hook payload acquired by Read-ClaudeHookRawPayload. An envelope
143
+ anomaly fails closed as a deny; a well-formed tool_input carrying no file_path
144
+ remains an allow (non-file tool calls have no file_path).
114
145
  #>
115
146
  [CmdletBinding()]
116
147
  [OutputType([System.Collections.Specialized.OrderedDictionary])]
117
148
  param(
149
+ [AllowNull()]
150
+ [AllowEmptyString()]
118
151
  [string] $ToolInputRaw
119
152
  )
120
153
 
121
- if (-not $ToolInputRaw) {
122
- return [ordered]@{ hookSpecificOutput = [ordered]@{ hookEventName = 'PreToolUse'; permissionDecision = 'allow' } }
154
+ $payload = Resolve-ClaudeHookToolInput -Raw $ToolInputRaw
155
+ if (-not $payload.IsValid) {
156
+ return Get-EvidenceLocationAnomalyDecision -Anomaly $payload.Anomaly
123
157
  }
124
158
 
125
- try {
126
- $toolInput = $ToolInputRaw | ConvertFrom-Json -ErrorAction Stop
127
- } catch {
128
- # Malformed JSON is a hard failure; caller exits 1 to surface the issue.
129
- throw "enforce-evidence-locations hook received malformed JSON in CLAUDE_TOOL_INPUT: $_"
130
- }
131
-
132
- $filePath = $toolInput.file_path
159
+ $filePath = Get-ClaudeHookToolInputString -ToolInput $payload.Value -Name 'file_path'
133
160
  if (-not $filePath) {
134
161
  return [ordered]@{ hookSpecificOutput = [ordered]@{ hookEventName = 'PreToolUse'; permissionDecision = 'allow' } }
135
162
  }
@@ -147,26 +174,34 @@ function Invoke-EvidenceLocationEntryPoint {
147
174
  Runs the evidence-location decision and returns the process exit code.
148
175
  .DESCRIPTION
149
176
  Wraps the dispatch logic that the hook entry point performs so it can be
150
- exercised by unit tests. On success it writes the compact JSON decision to
151
- the output stream and returns 0. On a hard failure (malformed JSON) it
152
- writes the error record and returns 1. This function does not call exit;
153
- the thin entry-point wiring converts the returned code into a process exit.
177
+ exercised by unit tests. It acquires the payload through the shared reader
178
+ unless the caller supplies one, writes the compact JSON decision to the output
179
+ stream, and returns 0. It never returns 1: exit 1 is non-blocking for
180
+ PreToolUse, so every envelope anomaly is already a deny decision by the time
181
+ control reaches here. This function does not call exit; the thin entry-point
182
+ wiring converts the returned code into a process exit.
154
183
  .PARAMETER ToolInputRaw
155
- The raw JSON string from $env:CLAUDE_TOOL_INPUT.
184
+ Optional pre-acquired payload text. When omitted the ReadPayload seam runs.
185
+ .PARAMETER ReadPayload
186
+ Seam for payload acquisition, so tests can drive the empty-on-all-transports
187
+ case without touching a console.
156
188
  #>
157
189
  [CmdletBinding()]
158
190
  [OutputType([int])]
159
191
  param(
160
- [string] $ToolInputRaw = $env:CLAUDE_TOOL_INPUT
192
+ [AllowNull()]
193
+ [AllowEmptyString()]
194
+ [string] $ToolInputRaw,
195
+
196
+ [scriptblock] $ReadPayload = { Read-ClaudeHookRawPayload }
161
197
  )
162
198
 
163
- try {
164
- $decision = Invoke-EvidenceLocationDecision -ToolInputRaw $ToolInputRaw
165
- } catch {
166
- Write-Error $_
167
- return 1
199
+ if (-not $PSBoundParameters.ContainsKey('ToolInputRaw')) {
200
+ $ToolInputRaw = [string](& $ReadPayload)
168
201
  }
169
202
 
203
+ $decision = Invoke-EvidenceLocationDecision -ToolInputRaw $ToolInputRaw
204
+
170
205
  $decision | ConvertTo-Json -Compress -Depth 5 | Write-Output
171
206
 
172
207
  return 0
@@ -177,4 +212,12 @@ if ($MyInvocation.InvocationName -eq '.') {
177
212
  return
178
213
  }
179
214
 
180
- exit (Invoke-EvidenceLocationEntryPoint)
215
+ # The entry point returns its [int] exit code as the last pipeline element and the
216
+ # decision JSON before it. `exit (<call>)` would capture BOTH into the exit
217
+ # expression and emit nothing, so the decision is written explicitly here first.
218
+ $entryPointResult = @(Invoke-EvidenceLocationEntryPoint)
219
+ if ($entryPointResult.Count -gt 1) {
220
+ $entryPointResult[0..($entryPointResult.Count - 2)] | Write-Output
221
+ }
222
+
223
+ exit ([int]$entryPointResult[-1])