@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.
- package/out/mcp-server.js +814 -194
- package/package.json +1 -1
- package/resources/claude-customizations/.claude/agents/commit-message.md +42 -0
- package/resources/claude-customizations/.claude/agents/epic-orchestrator.md +127 -0
- package/resources/claude-customizations/.claude/agents/human-exception-runbook.md +48 -0
- package/resources/claude-customizations/.claude/agents/orchestrator.md +67 -2
- package/resources/claude-customizations/.claude/agents/pr-author.md +37 -21
- package/resources/claude-customizations/.claude/hooks/check-powershell-test-purity.ps1 +103 -65
- package/resources/claude-customizations/.claude/hooks/check-python-test-purity.ps1 +12 -9
- package/resources/claude-customizations/.claude/hooks/enforce-checkpoint-monotonic.ps1 +24 -16
- package/resources/claude-customizations/.claude/hooks/enforce-completion-consistency.ps1 +19 -13
- package/resources/claude-customizations/.claude/hooks/enforce-epic-merge-gate.ps1 +304 -0
- package/resources/claude-customizations/.claude/hooks/enforce-epic-wave-barrier.ps1 +304 -0
- package/resources/claude-customizations/.claude/hooks/enforce-epic-worktree-removal-gate.ps1 +237 -0
- package/resources/claude-customizations/.claude/hooks/enforce-evidence-locations.ps1 +16 -12
- package/resources/claude-customizations/.claude/hooks/enforce-feature-folder-order.ps1 +14 -10
- package/resources/claude-customizations/.claude/hooks/enforce-orchestration-preimplementation-gate.ps1 +35 -8
- package/resources/claude-customizations/.claude/hooks/enforce-powershell-batch-budget.ps1 +17 -14
- package/resources/claude-customizations/.claude/hooks/enforce-pr-author-skill.epic-base-branch.ps1 +104 -0
- package/resources/claude-customizations/.claude/hooks/enforce-pr-author-skill.ps1 +268 -93
- package/resources/claude-customizations/.claude/hooks/enforce-prd-feature-before-planner.ps1 +19 -11
- package/resources/claude-customizations/.claude/hooks/enforce-promotion-mcp-only.ps1 +28 -6
- package/resources/claude-customizations/.claude/hooks/enforce-python-batch-budget.ps1 +17 -14
- package/resources/claude-customizations/.claude/hooks/validate-bash.ps1 +146 -36
- package/resources/claude-customizations/.claude/hooks/validate-orchestrator-output.ps1 +19 -7
- package/resources/claude-customizations/.claude/rules/orchestrator-state.md +36 -0
- package/resources/claude-customizations/.claude/settings.json +34 -1
- package/resources/claude-customizations/.claude/skills/epic-orchestrate/SKILL.md +252 -0
- package/resources/claude-customizations/.claude/skills/orchestrate/SKILL.md +42 -13
- package/resources/claude-customizations/.claude/skills/pr-author/SKILL.md +28 -0
- package/resources/claude-customizations/.claude/skills/remediation-handoff-atomic-planner/SKILL.md +22 -18
- package/resources/claude-customizations/pack-manifests/core.json +8 -0
- package/resources/codex-and-agents-customizations/.agents/skills/orchestrate/SKILL.md +6 -3
- package/resources/codex-and-agents-customizations/.agents/skills/remediation-handoff-atomic-planner/SKILL.md +1 -1
- package/resources/codex-and-agents-customizations/.agents-variants/csharp-legacy/skills/csharp/SKILL.md +65 -0
- package/resources/codex-and-agents-customizations/.agents-variants/csharp-legacy/skills/csharp-qa-gate/SKILL.md +79 -0
- package/resources/codex-and-agents-customizations/.agents-variants/csharp-legacy/skills/invoke-csharp-engineer/SKILL.md +66 -0
- package/resources/codex-and-agents-customizations/.codex/config.toml +1 -25
- package/resources/codex-and-agents-customizations/.codex/hooks/enforce-pr-author-skill.ps1 +257 -93
- package/resources/codex-and-agents-customizations/.codex-variants/csharp-legacy/agents/csharp-typed-engineer.toml +98 -0
- package/resources/codex-and-agents-customizations/pack-manifests/core.json +24 -0
- package/resources/codex-and-agents-customizations/pack-manifests/csharp-legacy.json +11 -0
- package/resources/codex-and-agents-customizations/pack-manifests/csharp-modern.json +10 -0
- package/resources/codex-and-agents-customizations/pack-manifests/powershell.json +15 -0
- package/resources/codex-and-agents-customizations/pack-manifests/python.json +15 -0
- package/resources/codex-and-agents-customizations/pack-manifests/typescript.json +9 -0
- package/resources/config/orchestration-routing.json +92 -0
- package/resources/customizations/.github/agents/pr-author.agent.md +24 -18
- package/resources/icon.png +0 -0
- package/resources/powershell/PoshQC/settings/pester.runsettings.psd1 +11 -0
- package/resources/codex-and-agents-customizations/.github/workflows/_validate-orchestrator-state.yml +0 -68
- 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
|
-
|
|
42
|
-
|
|
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
|
|
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
|
|
80
|
+
return $null
|
|
78
81
|
}
|
|
79
82
|
|
|
80
83
|
if (-not (Test-PythonTestFilePath -FilePath $filePath)) {
|
|
81
|
-
return
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
31
|
-
|
|
32
|
-
|
|
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]@{
|
|
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]@{
|
|
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]@{
|
|
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]@{
|
|
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]@{
|
|
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]@{
|
|
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]@{
|
|
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
|
-
|
|
266
|
-
|
|
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
|
-
|
|
281
|
-
|
|
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]@{
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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]@{
|
|
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]@{
|
|
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]@{
|
|
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]@{
|
|
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]@{
|
|
375
|
+
return [ordered]@{ hookSpecificOutput = [ordered]@{ hookEventName = 'PreToolUse'; permissionDecision = 'allow' } }
|
|
374
376
|
}
|
|
375
377
|
|
|
376
378
|
if (-not (Test-CompletionAsserted -Payload $payload)) {
|
|
377
|
-
return [ordered]@{
|
|
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]@{
|
|
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
|
-
|
|
391
|
-
|
|
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
|