@danmoisan/drm-copilot-mcp 1.0.0 → 1.0.7

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 (52) hide show
  1. package/out/mcp-server.js +814 -194
  2. package/package.json +1 -1
  3. package/resources/claude-customizations/.claude/agents/commit-message.md +42 -0
  4. package/resources/claude-customizations/.claude/agents/epic-orchestrator.md +127 -0
  5. package/resources/claude-customizations/.claude/agents/human-exception-runbook.md +48 -0
  6. package/resources/claude-customizations/.claude/agents/orchestrator.md +67 -2
  7. package/resources/claude-customizations/.claude/agents/pr-author.md +37 -21
  8. package/resources/claude-customizations/.claude/hooks/check-powershell-test-purity.ps1 +103 -65
  9. package/resources/claude-customizations/.claude/hooks/check-python-test-purity.ps1 +12 -9
  10. package/resources/claude-customizations/.claude/hooks/enforce-checkpoint-monotonic.ps1 +24 -16
  11. package/resources/claude-customizations/.claude/hooks/enforce-completion-consistency.ps1 +19 -13
  12. package/resources/claude-customizations/.claude/hooks/enforce-epic-merge-gate.ps1 +304 -0
  13. package/resources/claude-customizations/.claude/hooks/enforce-epic-wave-barrier.ps1 +304 -0
  14. package/resources/claude-customizations/.claude/hooks/enforce-epic-worktree-removal-gate.ps1 +237 -0
  15. package/resources/claude-customizations/.claude/hooks/enforce-evidence-locations.ps1 +16 -12
  16. package/resources/claude-customizations/.claude/hooks/enforce-feature-folder-order.ps1 +14 -10
  17. package/resources/claude-customizations/.claude/hooks/enforce-orchestration-preimplementation-gate.ps1 +35 -8
  18. package/resources/claude-customizations/.claude/hooks/enforce-powershell-batch-budget.ps1 +17 -14
  19. package/resources/claude-customizations/.claude/hooks/enforce-pr-author-skill.epic-base-branch.ps1 +104 -0
  20. package/resources/claude-customizations/.claude/hooks/enforce-pr-author-skill.ps1 +268 -93
  21. package/resources/claude-customizations/.claude/hooks/enforce-prd-feature-before-planner.ps1 +19 -11
  22. package/resources/claude-customizations/.claude/hooks/enforce-promotion-mcp-only.ps1 +28 -6
  23. package/resources/claude-customizations/.claude/hooks/enforce-python-batch-budget.ps1 +17 -14
  24. package/resources/claude-customizations/.claude/hooks/validate-bash.ps1 +146 -36
  25. package/resources/claude-customizations/.claude/hooks/validate-orchestrator-output.ps1 +19 -7
  26. package/resources/claude-customizations/.claude/rules/orchestrator-state.md +36 -0
  27. package/resources/claude-customizations/.claude/settings.json +34 -1
  28. package/resources/claude-customizations/.claude/skills/epic-orchestrate/SKILL.md +252 -0
  29. package/resources/claude-customizations/.claude/skills/orchestrate/SKILL.md +42 -13
  30. package/resources/claude-customizations/.claude/skills/pr-author/SKILL.md +28 -0
  31. package/resources/claude-customizations/.claude/skills/remediation-handoff-atomic-planner/SKILL.md +22 -18
  32. package/resources/claude-customizations/pack-manifests/core.json +8 -0
  33. package/resources/codex-and-agents-customizations/.agents/skills/orchestrate/SKILL.md +6 -3
  34. package/resources/codex-and-agents-customizations/.agents/skills/remediation-handoff-atomic-planner/SKILL.md +1 -1
  35. package/resources/codex-and-agents-customizations/.agents-variants/csharp-legacy/skills/csharp/SKILL.md +65 -0
  36. package/resources/codex-and-agents-customizations/.agents-variants/csharp-legacy/skills/csharp-qa-gate/SKILL.md +79 -0
  37. package/resources/codex-and-agents-customizations/.agents-variants/csharp-legacy/skills/invoke-csharp-engineer/SKILL.md +66 -0
  38. package/resources/codex-and-agents-customizations/.codex/config.toml +1 -25
  39. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-pr-author-skill.ps1 +257 -93
  40. package/resources/codex-and-agents-customizations/.codex-variants/csharp-legacy/agents/csharp-typed-engineer.toml +98 -0
  41. package/resources/codex-and-agents-customizations/pack-manifests/core.json +24 -0
  42. package/resources/codex-and-agents-customizations/pack-manifests/csharp-legacy.json +11 -0
  43. package/resources/codex-and-agents-customizations/pack-manifests/csharp-modern.json +10 -0
  44. package/resources/codex-and-agents-customizations/pack-manifests/powershell.json +15 -0
  45. package/resources/codex-and-agents-customizations/pack-manifests/python.json +15 -0
  46. package/resources/codex-and-agents-customizations/pack-manifests/typescript.json +9 -0
  47. package/resources/config/orchestration-routing.json +92 -0
  48. package/resources/customizations/.github/agents/pr-author.agent.md +24 -18
  49. package/resources/icon.png +0 -0
  50. package/resources/powershell/PoshQC/settings/pester.runsettings.psd1 +11 -0
  51. package/resources/codex-and-agents-customizations/.github/workflows/_validate-orchestrator-state.yml +0 -68
  52. package/resources/codex-and-agents-customizations/.github/workflows/validate-orchestrator-state.yml +0 -15
@@ -38,8 +38,11 @@ function Get-PythonTestPurityBlockDecision {
38
38
  )
39
39
 
40
40
  [ordered]@{
41
- decision = 'block'
42
- reason = $Reason
41
+ hookSpecificOutput = [ordered]@{
42
+ hookEventName = 'PreToolUse'
43
+ permissionDecision = 'deny'
44
+ permissionDecisionReason = $Reason
45
+ }
43
46
  }
44
47
  }
45
48
 
@@ -63,7 +66,7 @@ function Invoke-PythonTestPurityDecision {
63
66
  )
64
67
 
65
68
  if (-not $ToolInputRaw) {
66
- return [ordered]@{ decision = 'allow' }
69
+ return $null
67
70
  }
68
71
 
69
72
  try {
@@ -74,11 +77,11 @@ function Invoke-PythonTestPurityDecision {
74
77
 
75
78
  $filePath = $toolInput.file_path
76
79
  if (-not $filePath) {
77
- return [ordered]@{ decision = 'allow' }
80
+ return $null
78
81
  }
79
82
 
80
83
  if (-not (Test-PythonTestFilePath -FilePath $filePath)) {
81
- return [ordered]@{ decision = 'allow' }
84
+ return $null
82
85
  }
83
86
 
84
87
  $content = $null
@@ -89,7 +92,7 @@ function Invoke-PythonTestPurityDecision {
89
92
  }
90
93
 
91
94
  if (-not $content) {
92
- return [ordered]@{ decision = 'allow' }
95
+ return $null
93
96
  }
94
97
 
95
98
  $forbiddenPatterns = @(
@@ -125,7 +128,7 @@ function Invoke-PythonTestPurityDecision {
125
128
  }
126
129
 
127
130
  if ($violations.Count -eq 0) {
128
- return [ordered]@{ decision = 'allow' }
131
+ return $null
129
132
  }
130
133
 
131
134
  $uniqueViolations = $violations | Select-Object -Unique
@@ -139,8 +142,8 @@ if ($MyInvocation.InvocationName -eq '.') {
139
142
  }
140
143
 
141
144
  $decision = Invoke-PythonTestPurityDecision -ToolInputRaw $env:CLAUDE_TOOL_INPUT
142
- if ($decision.decision -eq 'block') {
143
- $decision | ConvertTo-Json -Compress | Write-Output
145
+ if ($null -ne $decision -and $decision.hookSpecificOutput.permissionDecision -eq 'deny') {
146
+ $decision | ConvertTo-Json -Compress -Depth 5 | Write-Output
144
147
  }
145
148
 
146
149
  exit 0
@@ -27,9 +27,11 @@
27
27
 
28
28
  Entries that do not match any canonical prefix are treated as informational
29
29
  and ignored. If any pair (i, j) with i < j has a higher canonical index at
30
- position i than at position j, the script blocks with a reason listing the
31
- offending pair. A non-empty rollback_history array suppresses this check
32
- because rollbacks legitimately reorder steps.
30
+ position i than at position j, the script denies via a PreToolUse JSON
31
+ response with hookSpecificOutput.permissionDecision='deny' and a reason
32
+ listing the offending pair. A non-empty rollback_history array suppresses
33
+ this check because rollbacks legitimately reorder steps. All allow paths
34
+ emit hookSpecificOutput.permissionDecision='allow'.
33
35
 
34
36
  Edit tool calls supply only old_string/new_string (a partial patch) and
35
37
  cannot be reliably validated without the full target file content, so they
@@ -204,7 +206,7 @@ function Invoke-CheckpointMonotonicDecision {
204
206
  )
205
207
 
206
208
  if (-not $ToolInputRaw) {
207
- return [ordered]@{ decision = 'allow' }
209
+ return [ordered]@{ hookSpecificOutput = [ordered]@{ hookEventName = 'PreToolUse'; permissionDecision = 'allow' } }
208
210
  }
209
211
 
210
212
  try {
@@ -216,19 +218,19 @@ function Invoke-CheckpointMonotonicDecision {
216
218
 
217
219
  $filePath = $toolInput.file_path
218
220
  if (-not $filePath) {
219
- return [ordered]@{ decision = 'allow' }
221
+ return [ordered]@{ hookSpecificOutput = [ordered]@{ hookEventName = 'PreToolUse'; permissionDecision = 'allow' } }
220
222
  }
221
223
 
222
224
  $normalized = $filePath -replace '\\', '/'
223
225
  if (-not (Test-IsCheckpointPath -NormalizedPath $normalized)) {
224
- return [ordered]@{ decision = 'allow' }
226
+ return [ordered]@{ hookSpecificOutput = [ordered]@{ hookEventName = 'PreToolUse'; permissionDecision = 'allow' } }
225
227
  }
226
228
 
227
229
  # Write tool: validate the content payload. Edit tool: partial new_string is
228
230
  # not reliable without the full target file content, so allow.
229
231
  $content = $toolInput.content
230
232
  if (-not $content) {
231
- return [ordered]@{ decision = 'allow' }
233
+ return [ordered]@{ hookSpecificOutput = [ordered]@{ hookEventName = 'PreToolUse'; permissionDecision = 'allow' } }
232
234
  }
233
235
 
234
236
  try {
@@ -237,11 +239,11 @@ function Invoke-CheckpointMonotonicDecision {
237
239
  catch {
238
240
  # The content itself is not valid JSON. Let downstream tools surface the
239
241
  # error rather than blocking with a misleading reason here.
240
- return [ordered]@{ decision = 'allow' }
242
+ return [ordered]@{ hookSpecificOutput = [ordered]@{ hookEventName = 'PreToolUse'; permissionDecision = 'allow' } }
241
243
  }
242
244
 
243
245
  if (-not $payload.PSObject.Properties.Name -contains 'completed_steps') {
244
- return [ordered]@{ decision = 'allow' }
246
+ return [ordered]@{ hookSpecificOutput = [ordered]@{ hookEventName = 'PreToolUse'; permissionDecision = 'allow' } }
245
247
  }
246
248
 
247
249
  $steps = @()
@@ -256,14 +258,17 @@ function Invoke-CheckpointMonotonicDecision {
256
258
  $rollbackHistory = $payload.rollback_history
257
259
  }
258
260
  if ($rollbackHistory -and @($rollbackHistory).Count -gt 0) {
259
- return [ordered]@{ decision = 'allow' }
261
+ return [ordered]@{ hookSpecificOutput = [ordered]@{ hookEventName = 'PreToolUse'; permissionDecision = 'allow' } }
260
262
  }
261
263
 
262
264
  $pair = if ($steps.Count -ge 2) { Get-OutOfOrderPair -CompletedSteps $steps } else { $null }
263
265
  if ($null -ne $pair) {
264
266
  return [ordered]@{
265
- decision = 'block'
266
- reason = "CHECKPOINT_ORDER_BLOCKED: completed_steps lists '$($pair.EarlierEntry)' at position $($pair.EarlierPos) before '$($pair.LaterEntry)' at position $($pair.LaterPos), but the canonical orchestrator workflow requires the later step to follow the earlier one. Reorder completed_steps or, if a rollback occurred, record it in rollback_history."
267
+ hookSpecificOutput = [ordered]@{
268
+ hookEventName = 'PreToolUse'
269
+ permissionDecision = 'deny'
270
+ permissionDecisionReason = "CHECKPOINT_ORDER_BLOCKED: completed_steps lists '$($pair.EarlierEntry)' at position $($pair.EarlierPos) before '$($pair.LaterEntry)' at position $($pair.LaterPos), but the canonical orchestrator workflow requires the later step to follow the earlier one. Reorder completed_steps or, if a rollback occurred, record it in rollback_history."
271
+ }
267
272
  }
268
273
  }
269
274
 
@@ -277,12 +282,15 @@ function Invoke-CheckpointMonotonicDecision {
277
282
  $missing += 'S4_atomic_planning'
278
283
  }
279
284
  return [ordered]@{
280
- decision = 'block'
281
- reason = "CHECKPOINT_ORDER_BLOCKED: completed_steps lists '$($missingPrerequisite.Step)' before required prerequisite step(s): $($missing -join ', '). Record promotion and planning completion before implementation, review, PR, CI, or DONE steps."
285
+ hookSpecificOutput = [ordered]@{
286
+ hookEventName = 'PreToolUse'
287
+ permissionDecision = 'deny'
288
+ permissionDecisionReason = "CHECKPOINT_ORDER_BLOCKED: completed_steps lists '$($missingPrerequisite.Step)' before required prerequisite step(s): $($missing -join ', '). Record promotion and planning completion before implementation, review, PR, CI, or DONE steps."
289
+ }
282
290
  }
283
291
  }
284
292
 
285
- return [ordered]@{ decision = 'allow' }
293
+ return [ordered]@{ hookSpecificOutput = [ordered]@{ hookEventName = 'PreToolUse'; permissionDecision = 'allow' } }
286
294
  }
287
295
 
288
296
  # Guard allows dot-sourcing in tests without executing the entrypoint.
@@ -298,6 +306,6 @@ catch {
298
306
  exit 1
299
307
  }
300
308
 
301
- $decision | ConvertTo-Json -Compress | Write-Output
309
+ $decision | ConvertTo-Json -Compress -Depth 5 | Write-Output
302
310
 
303
311
  exit 0
@@ -23,9 +23,11 @@
23
23
  variables.feature-folder);
24
24
  - a ci_gate object with conclusion == "success" and a non-empty head_sha.
25
25
 
26
- The block reason names the specific missing evidence so the caller can
27
- remediate. When completion is not asserted, the write is allowed
28
- (backward compatibility).
26
+ When completion evidence is missing the hook emits a PreToolUse JSON
27
+ response with hookSpecificOutput.permissionDecision='deny' and a reason that
28
+ names the specific missing evidence so the caller can remediate. When
29
+ completion is not asserted, the write is allowed via
30
+ hookSpecificOutput.permissionDecision='allow' (backward compatibility).
29
31
 
30
32
  Edit tool calls supply only old_string/new_string (a partial patch) and
31
33
  cannot be reliably validated without the full target file content, so they
@@ -331,7 +333,7 @@ function Invoke-CompletionConsistencyDecision {
331
333
  )
332
334
 
333
335
  if (-not $ToolInputRaw) {
334
- return [ordered]@{ decision = 'allow' }
336
+ return [ordered]@{ hookSpecificOutput = [ordered]@{ hookEventName = 'PreToolUse'; permissionDecision = 'allow' } }
335
337
  }
336
338
 
337
339
  try {
@@ -343,12 +345,12 @@ function Invoke-CompletionConsistencyDecision {
343
345
 
344
346
  $filePath = $toolInput.file_path
345
347
  if (-not $filePath) {
346
- return [ordered]@{ decision = 'allow' }
348
+ return [ordered]@{ hookSpecificOutput = [ordered]@{ hookEventName = 'PreToolUse'; permissionDecision = 'allow' } }
347
349
  }
348
350
 
349
351
  $normalized = $filePath -replace '\\', '/'
350
352
  if (-not (Test-IsCheckpointPath -NormalizedPath $normalized)) {
351
- return [ordered]@{ decision = 'allow' }
353
+ return [ordered]@{ hookSpecificOutput = [ordered]@{ hookEventName = 'PreToolUse'; permissionDecision = 'allow' } }
352
354
  }
353
355
 
354
356
  # Write tool: validate the content payload directly. Edit tool: no content is
@@ -360,7 +362,7 @@ function Invoke-CompletionConsistencyDecision {
360
362
  if (-not $content) {
361
363
  # No content, and the Edit could not be resolved against on-disk
362
364
  # state (missing file or non-matching patch): defer and allow.
363
- return [ordered]@{ decision = 'allow' }
365
+ return [ordered]@{ hookSpecificOutput = [ordered]@{ hookEventName = 'PreToolUse'; permissionDecision = 'allow' } }
364
366
  }
365
367
  }
366
368
 
@@ -370,11 +372,11 @@ function Invoke-CompletionConsistencyDecision {
370
372
  catch {
371
373
  # The content itself is not valid JSON. Let downstream tools surface the
372
374
  # error rather than blocking with a misleading reason here.
373
- return [ordered]@{ decision = 'allow' }
375
+ return [ordered]@{ hookSpecificOutput = [ordered]@{ hookEventName = 'PreToolUse'; permissionDecision = 'allow' } }
374
376
  }
375
377
 
376
378
  if (-not (Test-CompletionAsserted -Payload $payload)) {
377
- return [ordered]@{ decision = 'allow' }
379
+ return [ordered]@{ hookSpecificOutput = [ordered]@{ hookEventName = 'PreToolUse'; permissionDecision = 'allow' } }
378
380
  }
379
381
 
380
382
  $missingArgs = @{ Payload = $payload; FolderExistsCheck = $FolderExistsCheck }
@@ -383,12 +385,16 @@ function Invoke-CompletionConsistencyDecision {
383
385
  }
384
386
  $missing = Get-MissingCompletionEvidence @missingArgs
385
387
  if ($missing.Count -eq 0) {
386
- return [ordered]@{ decision = 'allow' }
388
+ return [ordered]@{ hookSpecificOutput = [ordered]@{ hookEventName = 'PreToolUse'; permissionDecision = 'allow' } }
387
389
  }
388
390
 
391
+ $reason = "COMPLETION_CONSISTENCY_BLOCKED: the checkpoint asserts completion but is missing required completion evidence: $($missing -join ', '). A completion-asserting checkpoint must include a non-empty issue-num, a non-empty feature-folder, and a ci_gate object with conclusion == 'success' and a non-empty head_sha; routes whose requires_pr_gate is true must also include pr_gate evidence with a matching head_sha. Supply the missing evidence or remove the completion assertion."
389
392
  return [ordered]@{
390
- decision = 'block'
391
- reason = "COMPLETION_CONSISTENCY_BLOCKED: the checkpoint asserts completion but is missing required completion evidence: $($missing -join ', '). A completion-asserting checkpoint must include a non-empty issue-num, a non-empty feature-folder, and a ci_gate object with conclusion == 'success' and a non-empty head_sha; routes whose requires_pr_gate is true must also include pr_gate evidence with a matching head_sha. Supply the missing evidence or remove the completion assertion."
393
+ hookSpecificOutput = [ordered]@{
394
+ hookEventName = 'PreToolUse'
395
+ permissionDecision = 'deny'
396
+ permissionDecisionReason = $reason
397
+ }
392
398
  }
393
399
  }
394
400
 
@@ -405,6 +411,6 @@ catch {
405
411
  exit 1
406
412
  }
407
413
 
408
- $decision | ConvertTo-Json -Compress | Write-Output
414
+ $decision | ConvertTo-Json -Compress -Depth 5 | Write-Output
409
415
 
410
416
  exit 0
@@ -0,0 +1,304 @@
1
+ <#
2
+ .SYNOPSIS
3
+ Pre-tool-use hook that gates gh pr merge --merge behind epic-mode checkpoint state.
4
+
5
+ .DESCRIPTION
6
+ Invoked by the Claude Code PreToolUse hook on the "Bash" matcher before any Bash
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
9
+ checkpoint-only conditions holds:
10
+
11
+ 1. Child-feature path: artifacts/orchestration/orchestrator-state.json exists,
12
+ epic_mode == true, and step9_status == "passed" (the per-feature orchestrator has
13
+ already run S9 step 6's CI-green gate before attempting its own merge-on-green).
14
+ 2. Epic-integration path: artifacts/orchestration/epic-orchestrator-state.json exists,
15
+ epic_merge_pr.ci_gate.conclusion == "success", and, when the command names an
16
+ explicit PR number, that number matches epic_merge_pr.pr_number.
17
+
18
+ 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.
22
+
23
+ Design decision: this gate trusts the on-disk checkpoint rather than shelling out live
24
+ to gh pr view for a real-time head-SHA check, matching the same non-adversarial,
25
+ policy-level-not-cryptographic posture already accepted for
26
+ enforce-pr-author-skill.ps1's own receipt mechanism. It is not a cryptographic control.
27
+
28
+ .NOTES
29
+ Compatible with PowerShell 7+. No external module dependencies. Filesystem reads go
30
+ through injectable wrapper functions so tests can mock the boundary without writing
31
+ temporary files.
32
+ #>
33
+ [CmdletBinding()]
34
+ param()
35
+
36
+ $script:ChildCheckpointPath = 'artifacts/orchestration/orchestrator-state.json'
37
+ $script:EpicCheckpointPath = 'artifacts/orchestration/epic-orchestrator-state.json'
38
+
39
+ function Get-ChildOrchestratorCheckpointContent {
40
+ <#
41
+ .SYNOPSIS
42
+ Read the raw JSON text of the per-feature orchestrator checkpoint. Tests mock
43
+ this function (read seam).
44
+ .OUTPUTS
45
+ System.String or $null
46
+ #>
47
+ [CmdletBinding()]
48
+ [OutputType([string])]
49
+ param()
50
+
51
+ if (-not (Test-Path -LiteralPath $script:ChildCheckpointPath -PathType Leaf)) {
52
+ return $null
53
+ }
54
+ return (Get-Content -LiteralPath $script:ChildCheckpointPath -Raw)
55
+ }
56
+
57
+ function Get-EpicOrchestratorCheckpointContent {
58
+ <#
59
+ .SYNOPSIS
60
+ Read the raw JSON text of the epic checkpoint. Tests mock this function
61
+ (read seam).
62
+ .OUTPUTS
63
+ System.String or $null
64
+ #>
65
+ [CmdletBinding()]
66
+ [OutputType([string])]
67
+ param()
68
+
69
+ if (-not (Test-Path -LiteralPath $script:EpicCheckpointPath -PathType Leaf)) {
70
+ return $null
71
+ }
72
+ return (Get-Content -LiteralPath $script:EpicCheckpointPath -Raw)
73
+ }
74
+
75
+ function ConvertFrom-EpicMergeGateJson {
76
+ <#
77
+ .SYNOPSIS
78
+ Parse checkpoint JSON text, returning $null on unreadable/invalid content.
79
+ .PARAMETER Raw
80
+ Raw checkpoint text, or $null when the file does not exist.
81
+ .OUTPUTS
82
+ System.Object or $null
83
+ #>
84
+ [CmdletBinding()]
85
+ param(
86
+ [AllowNull()]
87
+ [string] $Raw
88
+ )
89
+
90
+ if ([string]::IsNullOrWhiteSpace($Raw)) {
91
+ return $null
92
+ }
93
+ try {
94
+ return ($Raw | ConvertFrom-Json -ErrorAction Stop)
95
+ } catch {
96
+ return $null
97
+ }
98
+ }
99
+
100
+ function Get-EpicMergeGateCommandPrNumber {
101
+ <#
102
+ .SYNOPSIS
103
+ Extract an explicit PR number argument from a gh pr merge command, or $null.
104
+ .PARAMETER CommandText
105
+ The Bash command text under evaluation.
106
+ .OUTPUTS
107
+ System.Nullable[int]
108
+ #>
109
+ [CmdletBinding()]
110
+ [OutputType([int])]
111
+ param(
112
+ [Parameter(Mandatory)]
113
+ [string] $CommandText
114
+ )
115
+
116
+ if ($CommandText -match '(?i)\bgh\s+pr\s+merge\s+(\d+)\b') {
117
+ return [int]$Matches[1]
118
+ }
119
+ return $null
120
+ }
121
+
122
+ function Test-ChildCheckpointAllowsEpicMerge {
123
+ <#
124
+ .SYNOPSIS
125
+ Decision logic for the child-feature checkpoint path (branch 1).
126
+ .PARAMETER Checkpoint
127
+ Parsed child orchestrator checkpoint, or $null when absent/unreadable.
128
+ .OUTPUTS
129
+ System.Boolean
130
+ #>
131
+ [CmdletBinding()]
132
+ [OutputType([bool])]
133
+ param(
134
+ [AllowNull()]
135
+ $Checkpoint
136
+ )
137
+
138
+ if ($null -eq $Checkpoint) {
139
+ return $false
140
+ }
141
+ $props = @($Checkpoint.PSObject.Properties.Name)
142
+ if ($props -notcontains 'epic_mode' -or -not [bool]$Checkpoint.epic_mode) {
143
+ return $false
144
+ }
145
+ if ($props -notcontains 'step9_status') {
146
+ return $false
147
+ }
148
+ return ([string]$Checkpoint.step9_status) -eq 'passed'
149
+ }
150
+
151
+ function Test-EpicCheckpointAllowsMerge {
152
+ <#
153
+ .SYNOPSIS
154
+ Decision logic for the epic-integration checkpoint path (branch 2).
155
+ .PARAMETER Checkpoint
156
+ Parsed epic checkpoint, or $null when absent/unreadable.
157
+ .PARAMETER CommandPrNumber
158
+ The explicit PR number parsed from the command, or $null when the command
159
+ does not name one (implying the current branch's PR).
160
+ .OUTPUTS
161
+ System.Boolean
162
+ #>
163
+ [CmdletBinding()]
164
+ [OutputType([bool])]
165
+ param(
166
+ [AllowNull()]
167
+ $Checkpoint,
168
+
169
+ [AllowNull()]
170
+ [Nullable[int]] $CommandPrNumber
171
+ )
172
+
173
+ if ($null -eq $Checkpoint) {
174
+ return $false
175
+ }
176
+ $props = @($Checkpoint.PSObject.Properties.Name)
177
+ if ($props -notcontains 'epic_merge_pr' -or $null -eq $Checkpoint.epic_merge_pr) {
178
+ return $false
179
+ }
180
+ $mergePr = $Checkpoint.epic_merge_pr
181
+ $mergePrProps = @($mergePr.PSObject.Properties.Name)
182
+ if ($mergePrProps -notcontains 'ci_gate' -or $null -eq $mergePr.ci_gate) {
183
+ return $false
184
+ }
185
+ $ciGateProps = @($mergePr.ci_gate.PSObject.Properties.Name)
186
+ if ($ciGateProps -notcontains 'conclusion' -or ([string]$mergePr.ci_gate.conclusion) -ne 'success') {
187
+ return $false
188
+ }
189
+
190
+ # When the command names an explicit PR number, it must match the checkpoint's
191
+ # recorded epic_merge_pr.pr_number; a bare "gh pr merge --merge" implicitly targets
192
+ # the current branch's PR and is trusted per the checkpoint-only design decision.
193
+ if ($null -ne $CommandPrNumber) {
194
+ if ($mergePrProps -notcontains 'pr_number') {
195
+ return $false
196
+ }
197
+ $checkpointPrNumber = 0
198
+ if (-not [int]::TryParse([string]$mergePr.pr_number, [ref] $checkpointPrNumber)) {
199
+ return $false
200
+ }
201
+ if ($checkpointPrNumber -ne $CommandPrNumber) {
202
+ return $false
203
+ }
204
+ }
205
+
206
+ return $true
207
+ }
208
+
209
+ function Get-EpicMergeGateAllowDecision {
210
+ [CmdletBinding()]
211
+ [OutputType([System.Collections.Specialized.OrderedDictionary])]
212
+ param()
213
+
214
+ return [ordered]@{
215
+ hookSpecificOutput = [ordered]@{
216
+ hookEventName = 'PreToolUse'
217
+ permissionDecision = 'allow'
218
+ }
219
+ }
220
+ }
221
+
222
+ function Get-EpicMergeGateBlockDecision {
223
+ [CmdletBinding()]
224
+ [OutputType([System.Collections.Specialized.OrderedDictionary])]
225
+ param(
226
+ [Parameter(Mandatory)]
227
+ [string] $Reason
228
+ )
229
+
230
+ return [ordered]@{
231
+ hookSpecificOutput = [ordered]@{
232
+ hookEventName = 'PreToolUse'
233
+ permissionDecision = 'deny'
234
+ permissionDecisionReason = $Reason
235
+ }
236
+ }
237
+ }
238
+
239
+ function Invoke-EpicMergeGateDecision {
240
+ <#
241
+ .SYNOPSIS
242
+ Parses CLAUDE_TOOL_INPUT and returns an allow-or-block decision.
243
+ .PARAMETER ToolInputRaw
244
+ The raw JSON tool payload supplied by Claude Code.
245
+ .OUTPUTS
246
+ System.Collections.Specialized.OrderedDictionary
247
+ #>
248
+ [CmdletBinding()]
249
+ [OutputType([System.Collections.Specialized.OrderedDictionary])]
250
+ param(
251
+ [string] $ToolInputRaw
252
+ )
253
+
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: $_"
262
+ }
263
+
264
+ $commandText = $toolInput.command
265
+ if (-not $commandText) {
266
+ return Get-EpicMergeGateAllowDecision
267
+ }
268
+
269
+ # Only a gh pr merge invocation carrying --merge is in scope for this gate; every
270
+ # other Bash command is unaffected.
271
+ if ($commandText -notmatch '(?i)\bgh\s+pr\s+merge\b' -or $commandText -notmatch '--merge\b') {
272
+ return Get-EpicMergeGateAllowDecision
273
+ }
274
+
275
+ $commandPrNumber = Get-EpicMergeGateCommandPrNumber -CommandText $commandText
276
+
277
+ $childCheckpoint = ConvertFrom-EpicMergeGateJson -Raw (Get-ChildOrchestratorCheckpointContent)
278
+ if (Test-ChildCheckpointAllowsEpicMerge -Checkpoint $childCheckpoint) {
279
+ return Get-EpicMergeGateAllowDecision
280
+ }
281
+
282
+ $epicCheckpoint = ConvertFrom-EpicMergeGateJson -Raw (Get-EpicOrchestratorCheckpointContent)
283
+ if (Test-EpicCheckpointAllowsMerge -Checkpoint $epicCheckpoint -CommandPrNumber $commandPrNumber) {
284
+ return Get-EpicMergeGateAllowDecision
285
+ }
286
+
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.'
288
+ }
289
+
290
+ # Guard allows dot-sourcing in tests without executing the entrypoint.
291
+ if ($MyInvocation.InvocationName -eq '.') {
292
+ return
293
+ }
294
+
295
+ try {
296
+ $decision = Invoke-EpicMergeGateDecision -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