@danmoisan/drm-copilot-mcp 1.1.26 → 1.1.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/out/mcp-server.js +170 -52
  2. package/package.json +1 -1
  3. package/resources/claude-customizations/.claude/agents/atomic-executor.md +3 -3
  4. package/resources/claude-customizations/.claude/agents/atomic-planner.md +2 -2
  5. package/resources/claude-customizations/.claude/agents/csharp-typed-engineer.md +7 -7
  6. package/resources/claude-customizations/.claude/agents/feature-review.md +6 -3
  7. package/resources/claude-customizations/.claude/agents/orchestrator.md +11 -7
  8. package/resources/claude-customizations/.claude/agents/powershell-typed-engineer.md +7 -7
  9. package/resources/claude-customizations/.claude/agents/python-typed-engineer.md +6 -6
  10. package/resources/claude-customizations/.claude/hooks/enforce-epic-merge-gate-review.ps1 +699 -0
  11. package/resources/claude-customizations/.claude/hooks/enforce-epic-merge-gate.ps1 +16 -1
  12. package/resources/claude-customizations/.claude/hooks/validate-feature-review-coverage-rules.ps1 +50 -16
  13. package/resources/claude-customizations/.claude/lib/orchestrator-state/OrchestratorState.psm1 +6 -1
  14. package/resources/claude-customizations/.claude/lib/orchestrator-state/OrchestratorStateCompletion.psm1 +4 -2
  15. package/resources/claude-customizations/.claude/lib/orchestrator-state/OrchestratorStateCompletionChecks.psm1 +51 -2
  16. package/resources/claude-customizations/.claude/lib/orchestrator-state/OrchestratorStateReceipts.psm1 +5 -2
  17. package/resources/claude-customizations/.claude/lib/orchestrator-state/OrchestratorStateRemediationAccounting.psm1 +116 -4
  18. package/resources/claude-customizations/.claude/rules/csharp.md +8 -8
  19. package/resources/claude-customizations/.claude/rules/orchestrator-state.md +28 -2
  20. package/resources/claude-customizations/.claude/rules/powershell.md +2 -2
  21. package/resources/claude-customizations/.claude/skills/atomic-plan-contract/SKILL.md +8 -12
  22. package/resources/claude-customizations/.claude/skills/csharp-qa-gate/SKILL.md +14 -15
  23. package/resources/claude-customizations/.claude/skills/epic-progress-report/SKILL.md +338 -0
  24. package/resources/claude-customizations/.claude/skills/epic-progress-report/scripts/Get-EpicProgressReport.Logic.ps1 +248 -0
  25. package/resources/claude-customizations/.claude/skills/epic-progress-report/scripts/Get-EpicProgressReport.Metrics.ps1 +232 -0
  26. package/resources/claude-customizations/.claude/skills/epic-progress-report/scripts/Get-EpicProgressReport.Render.ps1 +196 -0
  27. package/resources/claude-customizations/.claude/skills/epic-progress-report/scripts/Get-EpicProgressReport.State.ps1 +286 -0
  28. package/resources/claude-customizations/.claude/skills/epic-progress-report/scripts/Get-EpicProgressReport.ps1 +475 -0
  29. package/resources/claude-customizations/.claude/skills/execution-environment/SKILL.md +18 -13
  30. package/resources/claude-customizations/.claude/skills/feature-review-workflow/SKILL.md +21 -9
  31. package/resources/claude-customizations/.claude/skills/invoke-csharp-engineer/SKILL.md +5 -5
  32. package/resources/claude-customizations/.claude/skills/invoke-powershell-engineer/SKILL.md +4 -4
  33. package/resources/claude-customizations/.claude/skills/invoke-python-engineer/SKILL.md +4 -4
  34. package/resources/claude-customizations/.claude/skills/orchestrate/SKILL.md +24 -2
  35. package/resources/claude-customizations/.claude/skills/parallel-orchestrate/SKILL.md +8 -0
  36. package/resources/claude-customizations/.claude/skills/powershell-qa-gate/SKILL.md +16 -17
  37. package/resources/claude-customizations/.claude/skills/python-qa-gate/SKILL.md +15 -16
  38. package/resources/claude-customizations/.claude/skills/remediation-handoff-atomic-planner/SKILL.md +1 -1
  39. package/resources/claude-customizations/.claude-variants/csharp-legacy/agents/csharp-typed-engineer.md +6 -6
  40. package/resources/claude-customizations/.claude-variants/csharp-legacy/skills/csharp-qa-gate/SKILL.md +13 -14
  41. package/resources/claude-customizations/.claude-variants/csharp-legacy/skills/invoke-csharp-engineer/SKILL.md +4 -4
  42. package/resources/claude-customizations/pack-manifests/core.json +7 -0
  43. package/resources/codex-and-agents-customizations/.agents/skills/atomic-plan-contract/SKILL.md +8 -12
  44. package/resources/codex-and-agents-customizations/.agents/skills/atomic-planner/SKILL.md +1 -1
  45. package/resources/codex-and-agents-customizations/.agents/skills/csharp-qa-gate/SKILL.md +13 -14
  46. package/resources/codex-and-agents-customizations/.agents/skills/execution-environment/SKILL.md +18 -13
  47. package/resources/codex-and-agents-customizations/.agents/skills/feature-review/SKILL.md +5 -1
  48. package/resources/codex-and-agents-customizations/.agents/skills/feature-review-workflow/SKILL.md +18 -5
  49. package/resources/codex-and-agents-customizations/.agents/skills/invoke-csharp-engineer/SKILL.md +4 -4
  50. package/resources/codex-and-agents-customizations/.agents/skills/invoke-powershell-engineer/SKILL.md +4 -4
  51. package/resources/codex-and-agents-customizations/.agents/skills/invoke-python-engineer/SKILL.md +4 -4
  52. package/resources/codex-and-agents-customizations/.agents/skills/orchestrate/SKILL.md +24 -2
  53. package/resources/codex-and-agents-customizations/.agents/skills/orchestrator-state/SKILL.md +16 -1
  54. package/resources/codex-and-agents-customizations/.agents/skills/orchestrator-workflow/SKILL.md +3 -3
  55. package/resources/codex-and-agents-customizations/.agents/skills/powershell/SKILL.md +2 -2
  56. package/resources/codex-and-agents-customizations/.agents/skills/powershell-qa-gate/SKILL.md +15 -16
  57. package/resources/codex-and-agents-customizations/.agents/skills/python-qa-gate/SKILL.md +14 -15
  58. package/resources/codex-and-agents-customizations/.agents/skills/repo-automation-adapter/SKILL.md +2 -0
  59. package/resources/codex-and-agents-customizations/.agents-variants/csharp-legacy/skills/csharp-qa-gate/SKILL.md +13 -14
  60. package/resources/codex-and-agents-customizations/.agents-variants/csharp-legacy/skills/invoke-csharp-engineer/SKILL.md +4 -4
  61. package/resources/codex-and-agents-customizations/.codex/agents/atomic-executor-c1.toml +3 -3
  62. package/resources/codex-and-agents-customizations/.codex/agents/atomic-executor-c2.toml +3 -3
  63. package/resources/codex-and-agents-customizations/.codex/agents/atomic-executor-c3-elevated.toml +3 -3
  64. package/resources/codex-and-agents-customizations/.codex/agents/atomic-executor-c3.toml +3 -3
  65. package/resources/codex-and-agents-customizations/.codex/agents/atomic-executor-c4.toml +3 -3
  66. package/resources/codex-and-agents-customizations/.codex/agents/atomic-executor.toml +3 -3
  67. package/resources/codex-and-agents-customizations/.codex/agents/atomic-planner-c1.toml +2 -2
  68. package/resources/codex-and-agents-customizations/.codex/agents/atomic-planner-c2.toml +2 -2
  69. package/resources/codex-and-agents-customizations/.codex/agents/atomic-planner-c3-elevated.toml +2 -2
  70. package/resources/codex-and-agents-customizations/.codex/agents/atomic-planner-c3.toml +2 -2
  71. package/resources/codex-and-agents-customizations/.codex/agents/atomic-planner-c4.toml +2 -2
  72. package/resources/codex-and-agents-customizations/.codex/agents/atomic-planner.toml +2 -2
  73. package/resources/codex-and-agents-customizations/.codex/agents/csharp-atomic-executor.toml +2 -2
  74. package/resources/codex-and-agents-customizations/.codex/agents/csharp-atomic-planning.toml +1 -1
  75. package/resources/codex-and-agents-customizations/.codex/agents/csharp-orchestrator.toml +1 -1
  76. package/resources/codex-and-agents-customizations/.codex/agents/csharp-typed-engineer-c1.toml +6 -6
  77. package/resources/codex-and-agents-customizations/.codex/agents/csharp-typed-engineer-c2.toml +6 -6
  78. package/resources/codex-and-agents-customizations/.codex/agents/csharp-typed-engineer-c3-elevated.toml +6 -6
  79. package/resources/codex-and-agents-customizations/.codex/agents/csharp-typed-engineer-c3.toml +6 -6
  80. package/resources/codex-and-agents-customizations/.codex/agents/csharp-typed-engineer-c4.toml +6 -6
  81. package/resources/codex-and-agents-customizations/.codex/agents/csharp-typed-engineer.toml +6 -6
  82. package/resources/codex-and-agents-customizations/.codex/agents/feature-review.toml +2 -2
  83. package/resources/codex-and-agents-customizations/.codex/agents/powershell-atomic-executor.toml +2 -2
  84. package/resources/codex-and-agents-customizations/.codex/agents/powershell-atomic-planning.toml +1 -1
  85. package/resources/codex-and-agents-customizations/.codex/agents/powershell-di-unit-test-engineer.toml +1 -1
  86. package/resources/codex-and-agents-customizations/.codex/agents/powershell-orchestrator.toml +1 -1
  87. package/resources/codex-and-agents-customizations/.codex/agents/powershell-typed-engineer-c1.toml +7 -7
  88. package/resources/codex-and-agents-customizations/.codex/agents/powershell-typed-engineer-c2.toml +7 -7
  89. package/resources/codex-and-agents-customizations/.codex/agents/powershell-typed-engineer-c3-elevated.toml +7 -7
  90. package/resources/codex-and-agents-customizations/.codex/agents/powershell-typed-engineer-c3.toml +7 -7
  91. package/resources/codex-and-agents-customizations/.codex/agents/powershell-typed-engineer-c4.toml +7 -7
  92. package/resources/codex-and-agents-customizations/.codex/agents/powershell-typed-engineer.toml +7 -7
  93. package/resources/codex-and-agents-customizations/.codex/agents/pytest-unit-test-coding.toml +1 -1
  94. package/resources/codex-and-agents-customizations/.codex/agents/python-atomic-executor.toml +2 -2
  95. package/resources/codex-and-agents-customizations/.codex/agents/python-atomic-planning.toml +1 -1
  96. package/resources/codex-and-agents-customizations/.codex/agents/python-execution-only-typed.toml +1 -1
  97. package/resources/codex-and-agents-customizations/.codex/agents/python-orchestrator.toml +1 -1
  98. package/resources/codex-and-agents-customizations/.codex/agents/python-typed-engineer-c1.toml +6 -6
  99. package/resources/codex-and-agents-customizations/.codex/agents/python-typed-engineer-c2.toml +6 -6
  100. package/resources/codex-and-agents-customizations/.codex/agents/python-typed-engineer-c3-elevated.toml +6 -6
  101. package/resources/codex-and-agents-customizations/.codex/agents/python-typed-engineer-c3.toml +6 -6
  102. package/resources/codex-and-agents-customizations/.codex/agents/python-typed-engineer-c4.toml +6 -6
  103. package/resources/codex-and-agents-customizations/.codex/agents/python-typed-engineer.toml +6 -6
  104. package/resources/codex-and-agents-customizations/.codex/config.toml +1 -1
  105. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-epic-merge-gate-review.ps1 +699 -0
  106. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-epic-merge-gate.ps1 +29 -2
  107. package/resources/codex-and-agents-customizations/.codex/hooks/validate-feature-review-coverage.ps1 +51 -4
  108. package/resources/codex-and-agents-customizations/.codex-variants/csharp-legacy/agents/csharp-typed-engineer.toml +6 -6
  109. package/resources/codex-and-agents-customizations/pack-manifests/core.json +1 -0
  110. package/resources/customizations/.github/agents/5.1-Beast-adjusted.agent.md +1 -1
  111. package/resources/customizations/.github/agents/Powershell DI Unit Test Engineer.agent.md +20 -26
  112. package/resources/customizations/.github/agents/atomic_executor.agent.md +4 -4
  113. package/resources/customizations/.github/agents/atomic_planning.agent.md +4 -4
  114. package/resources/customizations/.github/agents/commentary-remediation.agent.md +1 -1
  115. package/resources/customizations/.github/agents/commit-steward.agent.md +1 -1
  116. package/resources/customizations/.github/agents/csharp-atomic-executor.agent.md +14 -14
  117. package/resources/customizations/.github/agents/csharp-atomic-planning.agent.md +2 -2
  118. package/resources/customizations/.github/agents/csharp-orchestrator.agent.md +4 -4
  119. package/resources/customizations/.github/agents/csharp-typed-engineer.agent.md +19 -22
  120. package/resources/customizations/.github/agents/epic-review.agent.md +1 -1
  121. package/resources/customizations/.github/agents/gpt-5-beast-mode.agent.md +3 -3
  122. package/resources/customizations/.github/agents/orchestrator.agent.md +8 -8
  123. package/resources/customizations/.github/agents/powershell-atomic-executor.agent.md +12 -12
  124. package/resources/customizations/.github/agents/powershell-atomic-planning.agent.md +4 -4
  125. package/resources/customizations/.github/agents/powershell-orchestrator.agent.md +6 -6
  126. package/resources/customizations/.github/agents/powershell-typed-engineer.agent.md +21 -24
  127. package/resources/customizations/.github/agents/pytest-unit-test-coding.agent.md +13 -17
  128. package/resources/customizations/.github/agents/python-atomic-executor.agent.md +11 -11
  129. package/resources/customizations/.github/agents/python-atomic-planning.agent.md +2 -2
  130. package/resources/customizations/.github/agents/python-execution-only-typed.agent.md +14 -17
  131. package/resources/customizations/.github/agents/python-orchestrator.agent.md +7 -7
  132. package/resources/customizations/.github/agents/python-typed-engineer.agent.md +17 -20
  133. package/resources/customizations/.github/agents/staged-review.agent.md +2 -2
  134. package/resources/customizations/.github/agents/tdd-green.agent.md +1 -1
  135. package/resources/customizations/.github/agents/tdd-red.agent.md +3 -3
  136. package/resources/customizations/.github/agents/tdd-refactor.agent.md +2 -2
  137. package/resources/customizations/.github/agents/typescript-engineer.agent.md +3 -3
  138. package/resources/customizations/.github/codex/execute-hard-lock.prompt.md +3 -3
  139. package/resources/customizations/.github/instructions/general-code-change.instructions.md +1 -1
  140. package/resources/customizations/.github/instructions/powershell-unit-test.instructions.md +1 -1
  141. package/resources/customizations/.github/instructions/python-code-change.instructions.md +5 -5
  142. package/resources/customizations/.github/instructions/python-suppressions.instructions.md +3 -3
  143. package/resources/customizations/.github/instructions/python-unit-test.instructions.md +1 -1
  144. package/resources/customizations/.github/prompts/execute-hard-lock.prompt.md +2 -2
  145. package/resources/customizations/.github/prompts/generate-commit-message-repo.prompt.md +2 -2
  146. package/resources/customizations/.github/prompts/remediate-comments.prompt.md +2 -2
  147. package/resources/customizations/.github/prompts/review-feature.prompt.md +1 -1
  148. package/resources/customizations/.github/skills/atomic-plan-contract/SKILL.md +8 -12
  149. package/resources/customizations/.github/skills/execution-environment/SKILL.md +18 -13
  150. package/resources/customizations/.github/skills/feature-review-workflow/SKILL.md +15 -2
  151. package/resources/feature-templates/bug/plan.yyyy-MM-ddTHH-mm.md +4 -4
  152. package/resources/feature-templates/feature/plan.yyyy-MM-ddTHH-mm.md +3 -3
  153. package/resources/feature-templates/refactor/plan.yyyy-MM-ddTHH-mm.md +2 -2
  154. package/resources/powershell/PoshQC/PoshQC.Testing.psm1 +2 -2
  155. package/resources/powershell/PoshQC/PoshQC.psm1 +3 -3
  156. package/resources/powershell/PoshQC/README.md +8 -8
  157. package/resources/powershell/PoshQC/settings/pester.runsettings.psd1 +2 -2
  158. package/resources/templates/policy_audit/AGENTS.md +9 -10
  159. package/resources/templates/policy_audit/policy-audit.yyyy-MM-ddTHH-mm.md +19 -20
@@ -0,0 +1,699 @@
1
+ <#
2
+ .SYNOPSIS
3
+ Review guard of the merge gate under review_mode concurrent (issue #1097).
4
+
5
+ .DESCRIPTION
6
+ Dot-sourced by enforce-epic-merge-gate.ps1 in both runtimes; this file is byte-identical in the
7
+ Claude and Codex hook folders. Each gate calls Get-EpicMergeGateReviewShapeDecision and then
8
+ Get-EpicMergeGateReviewDecision with the per-feature checkpoint it read, before any allow
9
+ branch. Under the key, a command the gates read reaches no allow branch without a passing review
10
+ at a current head, and text that could be a merge and cannot be bound is denied.
11
+
12
+ The key is the top-level checkpoint key review_mode. Only the exact string concurrent activates
13
+ the guard, compared ordinally; an absent key, null, sequential, a case variant, a number, and
14
+ every other non-string value mean sequential, and the guard then starts no gh call and denies
15
+ nothing; only the Claude shape read below can still resolve worktrees, for at most 4 numbers.
16
+ Test-EpicMergeGateReviewModeConcurrent holds that predicate.
17
+
18
+ Shape step, before the out-of-scope allow and the order below: text that could be a merge
19
+ (Test-EpicMergeGateReviewMergeText) and is not one plain gh pr merge of one number
20
+ (Get-EpicMergeGateReviewBoundPrNumber) is denied with REVIEW_GATE_HEAD_UNRESOLVED, before any
21
+ process, when a checkpoint the gate can read carries the key. Without the key the decision is
22
+ unchanged. On Claude an unbound command also reads the session and the item of each of the first
23
+ 4 numbers in its text (EpicMergeGateReviewCandidateCap), which can resolve worktrees; that read
24
+ is best-effort, so a failure sees no key and never denies.
25
+
26
+ Evaluation order; the first match ends it:
27
+ 1. The checkpoint is absent or the key is not exactly concurrent: inert, no process.
28
+ 2. The command names no pull request number: REVIEW_GATE_HEAD_UNRESOLVED, before any process.
29
+ Only a direct call of Get-EpicMergeGateReviewDecision reaches this step, because under
30
+ both gates the shape step denies a number-less merge text first.
31
+ 3. The last remediation_loop.review_outcomes entry is missing, is not an object, or its
32
+ verdict is not exactly PASS: REVIEW_GATE_NOT_PASSED, before any process.
33
+ 4. Its head_sha is absent or malformed: REVIEW_GATE_NOT_PASSED, before any process.
34
+ 5. The one bounded gh pr view <PR> --json headRefOid fails or is malformed:
35
+ REVIEW_GATE_HEAD_UNRESOLVED.
36
+ 6. head_sha equals the live head: pass.
37
+ 7. git merge-base --is-ancestor <reviewed> <live> exits 1: REVIEW_GATE_HEAD_STALE.
38
+ 8. That command exits anything other than 0 or 1: REVIEW_GATE_HEAD_UNRESOLVED.
39
+ 9. feature-folder is not a clean path below docs/features/: REVIEW_GATE_HEAD_UNRESOLVED.
40
+ 10. git diff --name-only -z of the reviewed and live heads fails, or its non-empty output does
41
+ not end with NUL: REVIEW_GATE_HEAD_UNRESOLVED.
42
+ 11. A listed path does not start with <feature-folder>/ (ordinal): REVIEW_GATE_HEAD_STALE,
43
+ naming the path.
44
+ 12. Otherwise, including an empty listing: pass.
45
+
46
+ A code change after the review is therefore stale, including a merge of origin/main, because
47
+ that diff lists code paths. The hook never fetches: a head that is not present locally fails
48
+ step 7 or 8 and denies. The guard applies to whichever checkpoint its gate hands it, with no
49
+ binding exemption, so an explicit PR number that the checkpoint does not bind fails at the head
50
+ comparison; an exemption for an unbound number would open a bypass on Codex, whose child branch
51
+ requires no PR binding.
52
+
53
+ Limits (issue #1097 decision D10): this guard is a policy-level control, not a security control.
54
+ It gates only Bash merge text it can read against a checkpoint it can read, so a same-session
55
+ checkpoint edit, a merge through another tool or surface, an item target that does not resolve,
56
+ and text it cannot read are not gated. The extra read covers at most the first 4 numbers of the
57
+ text, so a key held only by an item named after them is not found. Under the key the rule is
58
+ fail-closed and its trigger is wider than pr merge: prose such as pr (#12) or pr $HOME is denied,
59
+ and so is a plain merge followed by a line feed or ;. D10 in .claude/rules/orchestrator-state.md
60
+ (## Concurrent-Review Scope and Backward Compatibility) lists every limit.
61
+
62
+ .NOTES
63
+ Compatible with PowerShell 7+. This file dot-sources nothing and imports no module. It calls, at
64
+ run time only, Invoke-DocsOnlyGhExe, Invoke-DocsOnlyGitExe, and ConvertFrom-DocsOnlyNameList
65
+ (hook-docs-only-diff.ps1), which the gate loads before it calls this file's functions and of
66
+ which the two process wrappers bound each call and never throw. It also calls
67
+ Read-CommandLineInvocationSegment, ConvertTo-CommandLineNormalizedText, Get-CommandLineGlobalOption,
68
+ Get-CommandLineStructuralWalk, and Test-CommandLineTokenLiteral (hook-command-invocation.ps1 and
69
+ its siblings), and Test-EpicMergeGateApiMutationText and Get-EpicMergeGateSelectorPrNumber
70
+ (enforce-epic-merge-gate-command.ps1). Read-EpicMergeGateReviewItemCheckpoint and
71
+ Get-EpicMergeGateReviewUnscopedDecision are Claude-only: the Codex gate passes no reader.
72
+ The Claude gate runs gh in the session worktree and git in the item worktree; the Codex gate
73
+ runs both in the repository root.
74
+ #>
75
+
76
+ # Dash options and their values that may sit between two subcommand words of one invocation.
77
+ $script:EpicMergeGateReviewOptionGap = '(?:\s+-\S+(?:\s+[^\s-]\S*)?)*'
78
+
79
+ # Text that could be a merge, matched ordinally over the scan text: pr then merge, and pr then a
80
+ # subcommand the text cannot show, because a variable, substitution, or subshell supplies it.
81
+ $script:EpicMergeGateReviewMergePattern = '(?<![\w-])pr' + $script:EpicMergeGateReviewOptionGap + '\s+merge(?![\w-])'
82
+ $script:EpicMergeGateReviewDynamicPattern = '(?<![\w-])pr' + $script:EpicMergeGateReviewOptionGap + '\s+[$`(]'
83
+
84
+ # The REST merge path with any segment in the number position, and a GraphQL call.
85
+ $script:EpicMergeGateReviewRestPattern = '(?<![\w-])pulls/[^\s/]+/merge(?![\w-])'
86
+ $script:EpicMergeGateReviewGraphqlPattern = '(?<![\w-])api' + $script:EpicMergeGateReviewOptionGap + '\s+graphql(?![\w-])'
87
+ $script:EpicMergeGateReviewMutationPattern = '(?<![\w-])mutation(?![\w-])'
88
+
89
+ # The gh api flags that carry a GraphQL field, and the shell words that may lead a segment of a
90
+ # poll loop around a read-only query.
91
+ $script:EpicMergeGateReviewFieldFlags = @('-f', '-F', '--raw-field', '--field')
92
+ $script:EpicMergeGateReviewShellKeywords = @('do', 'then', 'else', 'elif', 'if', 'while', 'until', '!')
93
+
94
+ # The gh pr merge flags a bound command may carry. This is an allowlist, so --auto, --admin, -R,
95
+ # --repo, a redirection, and every unknown token fail by absence.
96
+ $script:EpicMergeGateReviewBoundFlags = @('--merge', '-m', '--squash', '-s', '--rebase', '-r', '--delete-branch', '-d', '--disable-auto')
97
+ $script:EpicMergeGateReviewBoundValueFlags = @('--match-head-commit', '--subject', '-t', '--body', '-b', '--body-file', '-F', '--author-email', '-A')
98
+
99
+ # The most candidate numbers the Claude shape read resolves, in text order, so an unbound command
100
+ # costs a bounded number of worktree resolutions (issue #1097 decision D10).
101
+ $script:EpicMergeGateReviewCandidateCap = 4
102
+
103
+ function Test-EpicMergeGateReviewModeConcurrent {
104
+ <#
105
+ .SYNOPSIS
106
+ True when a parsed checkpoint carries review_mode exactly equal to concurrent (pure).
107
+ .DESCRIPTION
108
+ The value is tested with -is [string] first, because -ceq on an array filters rather than
109
+ compares. The guard and the readiness relaxation share this meaning.
110
+ .PARAMETER Checkpoint
111
+ The parsed per-feature checkpoint, or $null when absent or unreadable.
112
+ .OUTPUTS
113
+ System.Boolean
114
+ #>
115
+ [CmdletBinding()]
116
+ [OutputType([bool])]
117
+ param([AllowNull()] $Checkpoint)
118
+
119
+ if ($Checkpoint -isnot [System.Management.Automation.PSCustomObject]) { return $false }
120
+ if (@($Checkpoint.PSObject.Properties.Name) -cnotcontains 'review_mode') { return $false }
121
+ $mode = $Checkpoint.review_mode
122
+ return ($mode -is [string]) -and ($mode -ceq 'concurrent')
123
+ }
124
+
125
+ function Test-EpicMergeGateReviewObjectId {
126
+ <#
127
+ .SYNOPSIS
128
+ True when a value is a full lowercase hexadecimal object id of 40 or 64 characters.
129
+ .PARAMETER Value
130
+ The candidate value; any non-string value returns $false.
131
+ .OUTPUTS
132
+ System.Boolean
133
+ #>
134
+ [CmdletBinding()]
135
+ [OutputType([bool])]
136
+ param([AllowNull()] $Value)
137
+
138
+ return ($Value -is [string]) -and ($Value -cmatch '\A[0-9a-f]{40}([0-9a-f]{24})?\z')
139
+ }
140
+
141
+ function Get-EpicMergeGateLastReviewOutcome {
142
+ <#
143
+ .SYNOPSIS
144
+ Return the last remediation_loop.review_outcomes entry when it is an object, else $null.
145
+ .DESCRIPTION
146
+ Last-entry semantics: only the final list element counts, so an earlier PASS never
147
+ satisfies the guard. A missing loop, a value that is not a list, an empty list, and a final
148
+ element that is not an object all return $null.
149
+ .PARAMETER Checkpoint
150
+ The parsed per-feature checkpoint.
151
+ .OUTPUTS
152
+ System.Management.Automation.PSCustomObject or $null
153
+ #>
154
+ [CmdletBinding()]
155
+ [OutputType([pscustomobject])]
156
+ param([AllowNull()] $Checkpoint)
157
+
158
+ $loop = $Checkpoint.remediation_loop
159
+ if ($loop -isnot [System.Management.Automation.PSCustomObject]) { return $null }
160
+ $outcomes = $loop.review_outcomes
161
+ if ($outcomes -isnot [System.Array] -or $outcomes.Count -eq 0) { return $null }
162
+ $last = $outcomes[$outcomes.Count - 1]
163
+ if ($last -isnot [System.Management.Automation.PSCustomObject]) { return $null }
164
+ return $last
165
+ }
166
+
167
+ function Test-EpicMergeGateReviewFeatureFolder {
168
+ <#
169
+ .SYNOPSIS
170
+ True when a value is a clean relative path below docs/features/ (pure).
171
+ .DESCRIPTION
172
+ A blank value, a value outside docs/features/ (which includes every absolute path), a
173
+ backslash, an empty segment (which includes a trailing slash), and a dot segment all return
174
+ $false, so a value such as . cannot widen the feature-folder rule.
175
+ .PARAMETER Value
176
+ The candidate feature-folder value; any non-string value returns $false.
177
+ .OUTPUTS
178
+ System.Boolean
179
+ #>
180
+ [CmdletBinding()]
181
+ [OutputType([bool])]
182
+ param([AllowNull()] $Value)
183
+
184
+ if ($Value -isnot [string] -or [string]::IsNullOrWhiteSpace($Value)) { return $false }
185
+ if (-not $Value.StartsWith('docs/features/', [System.StringComparison]::Ordinal)) { return $false }
186
+ if ($Value.IndexOf([char]92) -ge 0) { return $false }
187
+ foreach ($segment in $Value.Split('/')) {
188
+ if ($segment.Length -eq 0 -or $segment -ceq '.' -or $segment -ceq '..') { return $false }
189
+ }
190
+ return $true
191
+ }
192
+
193
+ function Test-EpicMergeGateReviewPathInFolder {
194
+ <#
195
+ .SYNOPSIS
196
+ True when a repository-relative path lies inside the feature folder (pure, ordinal).
197
+ .DESCRIPTION
198
+ The prefix carries the separator, so a sibling folder whose name extends the feature folder
199
+ does not match.
200
+ .PARAMETER Path
201
+ The repository-relative path a diff listed.
202
+ .PARAMETER FeatureFolder
203
+ The validated feature folder.
204
+ .OUTPUTS
205
+ System.Boolean
206
+ #>
207
+ [CmdletBinding()]
208
+ [OutputType([bool])]
209
+ param(
210
+ [Parameter(Mandatory)][string] $Path,
211
+ [Parameter(Mandatory)][string] $FeatureFolder
212
+ )
213
+
214
+ return $Path.StartsWith($FeatureFolder + '/', [System.StringComparison]::Ordinal)
215
+ }
216
+
217
+ function Get-EpicMergeGateReviewLiveHeadSha {
218
+ <#
219
+ .SYNOPSIS
220
+ Return the live head commit of a pull request, or $null on any failure (the live-head seam).
221
+ .DESCRIPTION
222
+ Runs gh pr view <PR> --json headRefOid through Invoke-DocsOnlyGhExe, which bounds the call
223
+ and never throws. This is the guard's own seam, because the green-CI sibling that holds a
224
+ similar function exists only on the Claude runtime. A non-zero exit, output that does not
225
+ parse to a JSON object, and a malformed head all return $null. Tests mock this by name.
226
+ .PARAMETER PrNumber
227
+ The pull request number.
228
+ .PARAMETER WorktreeRoot
229
+ The directory gh runs in.
230
+ .OUTPUTS
231
+ System.String or $null
232
+ #>
233
+ [CmdletBinding()]
234
+ [OutputType([string])]
235
+ param(
236
+ [Parameter(Mandatory)][int] $PrNumber,
237
+ [AllowNull()][AllowEmptyString()][string] $WorktreeRoot
238
+ )
239
+
240
+ $lookup = Invoke-DocsOnlyGhExe -WorkingRoot $WorktreeRoot -GhArgs @('pr', 'view', [string]$PrNumber, '--json', 'headRefOid')
241
+ if ($null -eq $lookup -or $lookup.ExitCode -ne 0) { return $null }
242
+ try {
243
+ $pr = ConvertFrom-Json -InputObject ([string]$lookup.Output) -ErrorAction Stop
244
+ }
245
+ catch {
246
+ # Unreadable gh output is a lookup failure; the guard fails closed on $null.
247
+ return $null
248
+ }
249
+ if ($pr -isnot [System.Management.Automation.PSCustomObject]) { return $null }
250
+ if (-not (Test-EpicMergeGateReviewObjectId -Value $pr.headRefOid)) { return $null }
251
+ return [string]$pr.headRefOid
252
+ }
253
+
254
+ function Get-EpicMergeGateReviewHeadState {
255
+ <#
256
+ .SYNOPSIS
257
+ Classify a reviewed head against the live head as Current, Stale, or Unresolved.
258
+ .DESCRIPTION
259
+ Evaluates steps 6 to 12 of the file header in order. A later head is Current only when the
260
+ reviewed head is its ancestor and every path changed since then lies inside the feature
261
+ folder, so review artifacts and evidence do not force a new review.
262
+ .PARAMETER ReviewedHead
263
+ The head_sha of the last review outcome.
264
+ .PARAMETER LiveHead
265
+ The live pull-request head.
266
+ .PARAMETER FeatureFolder
267
+ The checkpoint's feature-folder value, not yet validated.
268
+ .PARAMETER GitRoot
269
+ The directory git runs in.
270
+ .OUTPUTS
271
+ System.Collections.Specialized.OrderedDictionary with State and Detail.
272
+ #>
273
+ [CmdletBinding()]
274
+ [OutputType([System.Collections.Specialized.OrderedDictionary])]
275
+ param(
276
+ [Parameter(Mandatory)][string] $ReviewedHead,
277
+ [Parameter(Mandatory)][string] $LiveHead,
278
+ [AllowNull()] $FeatureFolder,
279
+ [AllowNull()][AllowEmptyString()][string] $GitRoot
280
+ )
281
+
282
+ if ([string]::Equals($ReviewedHead, $LiveHead, [System.StringComparison]::Ordinal)) {
283
+ return [ordered]@{ State = 'Current'; Detail = 'the reviewed head is the live head.' }
284
+ }
285
+
286
+ $ancestry = Invoke-DocsOnlyGitExe -WorktreeRoot $GitRoot -GitArgs @('merge-base', '--is-ancestor', $ReviewedHead, $LiveHead)
287
+ $ancestryExit = if ($null -eq $ancestry) { -1 } else { [int]$ancestry.ExitCode }
288
+ if ($ancestryExit -eq 1) {
289
+ return [ordered]@{ State = 'Stale'; Detail = "the reviewed head $ReviewedHead is not an ancestor of the live head $LiveHead." }
290
+ }
291
+ if ($ancestryExit -ne 0) {
292
+ return [ordered]@{ State = 'Unresolved'; Detail = "git merge-base could not compare the reviewed head $ReviewedHead with the live head $LiveHead; fetch the pull request head, then retry. This hook does not fetch." }
293
+ }
294
+
295
+ if (-not (Test-EpicMergeGateReviewFeatureFolder -Value $FeatureFolder)) {
296
+ return [ordered]@{ State = 'Unresolved'; Detail = 'the checkpoint feature-folder value is not a clean path below docs/features/.' }
297
+ }
298
+
299
+ $diff = Invoke-DocsOnlyGitExe -WorktreeRoot $GitRoot -GitArgs @('diff', '--name-only', '-z', '--no-renames', '--no-color', '--no-ext-diff', '--no-relative', $ReviewedHead, $LiveHead)
300
+ if ($null -eq $diff -or $diff.ExitCode -ne 0) {
301
+ return [ordered]@{ State = 'Unresolved'; Detail = "git diff of the reviewed head $ReviewedHead and the live head $LiveHead could not be computed." }
302
+ }
303
+ $listing = [string]$diff.Output
304
+ if ($listing.Length -gt 0 -and -not $listing.EndsWith([char]0)) {
305
+ return [ordered]@{ State = 'Unresolved'; Detail = 'git diff did not return NUL-terminated output.' }
306
+ }
307
+
308
+ foreach ($path in @(ConvertFrom-DocsOnlyNameList -Text $listing)) {
309
+ if (-not (Test-EpicMergeGateReviewPathInFolder -Path $path -FeatureFolder $FeatureFolder)) {
310
+ return [ordered]@{ State = 'Stale'; Detail = "the path $path changed after the reviewed head $ReviewedHead and lies outside $FeatureFolder." }
311
+ }
312
+ }
313
+ return [ordered]@{ State = 'Current'; Detail = 'only feature-folder paths changed after the reviewed head.' }
314
+ }
315
+
316
+ function Get-EpicMergeGateReviewDecision {
317
+ <#
318
+ .SYNOPSIS
319
+ Evaluate the review guard for one merge command and return Denied and Reason.
320
+ .DESCRIPTION
321
+ Applies steps 1 to 5 of the file header and delegates steps 6 to 12 to
322
+ Get-EpicMergeGateReviewHeadState. Every deny reason reads
323
+ EPIC_MERGE_GATE_BLOCKED: <code>: <message>. Without the key it returns immediately and
324
+ starts no process.
325
+ .PARAMETER Checkpoint
326
+ The parsed per-feature checkpoint, or $null.
327
+ .PARAMETER CommandPrNumber
328
+ The explicit PR number the command names, or $null for a bare command.
329
+ .PARAMETER GhRoot
330
+ The directory gh runs in.
331
+ .PARAMETER GitRoot
332
+ The directory git runs in.
333
+ .OUTPUTS
334
+ System.Collections.Specialized.OrderedDictionary with Denied and Reason.
335
+ #>
336
+ [CmdletBinding()]
337
+ [OutputType([System.Collections.Specialized.OrderedDictionary])]
338
+ param(
339
+ [AllowNull()] $Checkpoint,
340
+ [AllowNull()][Nullable[int]] $CommandPrNumber,
341
+ [AllowNull()][AllowEmptyString()][string] $GhRoot,
342
+ [AllowNull()][AllowEmptyString()][string] $GitRoot
343
+ )
344
+
345
+ $pass = [ordered]@{ Denied = $false; Reason = $null }
346
+ if (-not (Test-EpicMergeGateReviewModeConcurrent -Checkpoint $Checkpoint)) { return $pass }
347
+
348
+ if ($null -eq $CommandPrNumber) {
349
+ return [ordered]@{ Denied = $true; Reason = 'EPIC_MERGE_GATE_BLOCKED: REVIEW_GATE_HEAD_UNRESOLVED: review_mode concurrent requires a merge command that names the pull request number.' }
350
+ }
351
+
352
+ $outcome = Get-EpicMergeGateLastReviewOutcome -Checkpoint $Checkpoint
353
+ if ($null -eq $outcome -or $outcome.verdict -isnot [string] -or $outcome.verdict -cne 'PASS') {
354
+ return [ordered]@{ Denied = $true; Reason = 'EPIC_MERGE_GATE_BLOCKED: REVIEW_GATE_NOT_PASSED: review_mode concurrent requires a last review_outcomes entry with verdict PASS.' }
355
+ }
356
+ if (-not (Test-EpicMergeGateReviewObjectId -Value $outcome.head_sha)) {
357
+ return [ordered]@{ Denied = $true; Reason = 'EPIC_MERGE_GATE_BLOCKED: REVIEW_GATE_NOT_PASSED: review_mode concurrent requires a full lowercase head_sha on the last review_outcomes entry.' }
358
+ }
359
+
360
+ $liveHead = Get-EpicMergeGateReviewLiveHeadSha -PrNumber $CommandPrNumber -WorktreeRoot $GhRoot
361
+ if ($null -eq $liveHead) {
362
+ return [ordered]@{ Denied = $true; Reason = "EPIC_MERGE_GATE_BLOCKED: REVIEW_GATE_HEAD_UNRESOLVED: the live head of pull request $CommandPrNumber could not be read." }
363
+ }
364
+
365
+ $headState = Get-EpicMergeGateReviewHeadState -ReviewedHead ([string]$outcome.head_sha) -LiveHead $liveHead -FeatureFolder $Checkpoint.'feature-folder' -GitRoot $GitRoot
366
+ if ($headState.State -ceq 'Current') { return $pass }
367
+ $code = if ($headState.State -ceq 'Stale') { 'REVIEW_GATE_HEAD_STALE' } else { 'REVIEW_GATE_HEAD_UNRESOLVED' }
368
+ return [ordered]@{ Denied = $true; Reason = "EPIC_MERGE_GATE_BLOCKED: ${code}: $($headState.Detail)" }
369
+ }
370
+
371
+ function ConvertTo-EpicMergeGateReviewScanText {
372
+ <#
373
+ .SYNOPSIS
374
+ Return command text as the shell would join it, for a text scan (pure).
375
+ .DESCRIPTION
376
+ Removes each backslash line continuation, then every backslash and quote character, so
377
+ an escaped or quoted word reads as the word the shell passes on. RemoveBacktick also
378
+ removes backticks, which are escape characters in PowerShell.
379
+ .PARAMETER CommandText
380
+ The raw command text.
381
+ .PARAMETER RemoveBacktick
382
+ Produce the second scan form.
383
+ .OUTPUTS
384
+ System.String
385
+ #>
386
+ [CmdletBinding()]
387
+ [OutputType([string])]
388
+ param(
389
+ [Parameter(Mandatory)][AllowEmptyString()][string] $CommandText,
390
+ [switch] $RemoveBacktick
391
+ )
392
+
393
+ $text = [regex]::Replace($CommandText, '\\\r?\n', '')
394
+ $text = [regex]::Replace($text, '[\\"'']', '')
395
+ if ($RemoveBacktick) { $text = $text.Replace('`', '') }
396
+ return $text
397
+ }
398
+
399
+ function Test-EpicMergeGateReviewQueryField {
400
+ <#
401
+ .SYNOPSIS
402
+ True when gh api graphql tokens carry a query field and every one is read-only (pure).
403
+ .PARAMETER Token
404
+ The tokens of the invocation.
405
+ .PARAMETER StartIndex
406
+ The position just past the subcommand path.
407
+ .OUTPUTS
408
+ System.Boolean
409
+ #>
410
+ [CmdletBinding()]
411
+ [OutputType([bool])]
412
+ param(
413
+ [Parameter(Mandatory)][AllowEmptyCollection()][AllowEmptyString()][string[]] $Token,
414
+ [Parameter(Mandatory)][int] $StartIndex
415
+ )
416
+
417
+ $found = $false
418
+ for ($index = $StartIndex; $index -lt $Token.Count; $index++) {
419
+ if ($Token[$index].StartsWith('--input', [System.StringComparison]::Ordinal)) { return $false }
420
+ if ($script:EpicMergeGateReviewFieldFlags -cnotcontains $Token[$index] -or $index + 1 -ge $Token.Count) { continue }
421
+ $field = $Token[$index + 1]
422
+ if (-not $field.StartsWith('query=', [System.StringComparison]::Ordinal)) { continue }
423
+ if ($field -cnotmatch '\Aquery=\s*(?:query|\{)') { return $false }
424
+ $found = $true
425
+ }
426
+ return $found
427
+ }
428
+
429
+ function Test-EpicMergeGateReviewGraphqlReadOnly {
430
+ <#
431
+ .SYNOPSIS
432
+ True when every gh api graphql occurrence of a command is proven read-only (pure).
433
+ .DESCRIPTION
434
+ Each textual occurrence must be a structural invocation, after leading shell keywords, whose
435
+ query field is a literal query, with no --input token and no word mutation in the text. A
436
+ command with no occurrence returns $true. The proof does not require a sole segment, so a
437
+ poll loop around the query stays allowed. An argument-injected record proves nothing.
438
+ .OUTPUTS
439
+ System.Boolean
440
+ #>
441
+ [CmdletBinding()]
442
+ [OutputType([bool])]
443
+ param([Parameter(Mandatory)][AllowEmptyString()][string] $CommandText)
444
+
445
+ $scan = ConvertTo-EpicMergeGateReviewScanText -CommandText $CommandText
446
+ $occurrences = [regex]::Matches($scan, $script:EpicMergeGateReviewGraphqlPattern).Count
447
+ if ($occurrences -eq 0) { return $true }
448
+ if ([regex]::IsMatch($scan, $script:EpicMergeGateReviewMutationPattern)) { return $false }
449
+
450
+ $option = Get-CommandLineGlobalOption -CommandWord 'gh'
451
+ $proven = 0
452
+ foreach ($record in @(Read-CommandLineInvocationSegment -CommandText $CommandText)) {
453
+ if ($record.ArgumentInjected) { continue }
454
+ $tokens = [string[]]@($record.Tokens)
455
+ $start = 0
456
+ while ($start -lt $tokens.Count -and $script:EpicMergeGateReviewShellKeywords -ccontains $tokens[$start]) { $start++ }
457
+ $command = [string[]]@($tokens | Select-Object -Skip $start)
458
+ $walk = Get-CommandLineStructuralWalk -Token $command -CommandWord 'gh' -SubcommandPath @('api', 'graphql') -Option $option
459
+ if ($walk.Outcome -ceq 'Match' -and (Test-EpicMergeGateReviewQueryField -Token $command -StartIndex $walk.OperandIndex)) { $proven++ }
460
+ }
461
+ return $proven -eq $occurrences
462
+ }
463
+
464
+ function Test-EpicMergeGateReviewApiTrigger {
465
+ <#
466
+ .SYNOPSIS
467
+ True when command text could reach a merge through an API (pure).
468
+ .DESCRIPTION
469
+ Names a merge or enqueue mutation, holds a pulls/<segment>/merge path, or holds a gh api
470
+ graphql occurrence that is not proven read-only.
471
+ .OUTPUTS
472
+ System.Boolean
473
+ #>
474
+ [CmdletBinding()]
475
+ [OutputType([bool])]
476
+ param([Parameter(Mandatory)][AllowEmptyString()][string] $CommandText)
477
+
478
+ foreach ($scan in @((ConvertTo-EpicMergeGateReviewScanText -CommandText $CommandText), (ConvertTo-EpicMergeGateReviewScanText -CommandText $CommandText -RemoveBacktick))) {
479
+ if ((Test-EpicMergeGateApiMutationText -Text $scan) -or [regex]::IsMatch($scan, $script:EpicMergeGateReviewRestPattern)) { return $true }
480
+ }
481
+ return -not (Test-EpicMergeGateReviewGraphqlReadOnly -CommandText $CommandText)
482
+ }
483
+
484
+ function Test-EpicMergeGateReviewMergeText {
485
+ <#
486
+ .SYNOPSIS
487
+ True when command text could be a merge (pure).
488
+ .DESCRIPTION
489
+ The trigger needs no gh word, so it also reads a variable or path in the command position
490
+ and an alias definition. It is deliberately wider than the scope predicate, and it decides
491
+ only whether the shape rule looks further: without the key the decision does not change.
492
+ .OUTPUTS
493
+ System.Boolean
494
+ #>
495
+ [CmdletBinding()]
496
+ [OutputType([bool])]
497
+ param([Parameter(Mandatory)][AllowEmptyString()][AllowNull()][string] $CommandText)
498
+
499
+ if ([string]::IsNullOrEmpty($CommandText)) { return $false }
500
+ foreach ($scan in @((ConvertTo-EpicMergeGateReviewScanText -CommandText $CommandText), (ConvertTo-EpicMergeGateReviewScanText -CommandText $CommandText -RemoveBacktick))) {
501
+ if ([regex]::IsMatch($scan, $script:EpicMergeGateReviewMergePattern) -or [regex]::IsMatch($scan, $script:EpicMergeGateReviewDynamicPattern)) { return $true }
502
+ }
503
+ return Test-EpicMergeGateReviewApiTrigger -CommandText $CommandText
504
+ }
505
+
506
+ function Get-EpicMergeGateReviewBoundTokenNumber {
507
+ <#
508
+ .SYNOPSIS
509
+ Return the one all-digit selector of gh pr merge tokens, or $null (pure).
510
+ .DESCRIPTION
511
+ The first three tokens must equal gh, pr, and merge ordinally. The rest must hold exactly
512
+ one selector and otherwise only allowlisted flags, each value flag with a literal value.
513
+ .PARAMETER Token
514
+ The tokens of the one segment.
515
+ .OUTPUTS
516
+ System.Nullable[int]
517
+ #>
518
+ [CmdletBinding()]
519
+ [OutputType([int])]
520
+ param([Parameter(Mandatory)][AllowEmptyCollection()][AllowEmptyString()][string[]] $Token)
521
+
522
+ if ($Token.Count -lt 4 -or $Token[0] -cne 'gh' -or $Token[1] -cne 'pr' -or $Token[2] -cne 'merge') { return $null }
523
+ $selector = $null
524
+ for ($index = 3; $index -lt $Token.Count; $index++) {
525
+ $current = $Token[$index]
526
+ if ($script:EpicMergeGateReviewBoundFlags -ccontains $current) { continue }
527
+ if ($script:EpicMergeGateReviewBoundValueFlags -ccontains $current) {
528
+ $index++
529
+ if ($index -ge $Token.Count -or -not (Test-CommandLineTokenLiteral -Token $Token[$index])) { return $null }
530
+ continue
531
+ }
532
+ if ($null -ne $selector -or $current -cnotmatch '\A[0-9]+\z') { return $null }
533
+ $selector = $current
534
+ }
535
+ $number = 0
536
+ if ($null -ne $selector -and [int]::TryParse($selector, [ref] $number)) { return $number }
537
+ return $null
538
+ }
539
+
540
+ function Get-EpicMergeGateReviewBoundPrNumber {
541
+ <#
542
+ .SYNOPSIS
543
+ Return the pull request number of a command that is one plain merge, or $null (pure).
544
+ .DESCRIPTION
545
+ The command must be exactly one top-level segment, balanced and not opaque, that ends the
546
+ text and spans all of it, so nothing runs before or after the merge. The text must hold one
547
+ pr merge occurrence and no API trigger. Any other text is not bound, which makes a chain
548
+ with a readable number unsafe even though the head lookup would pass.
549
+ .OUTPUTS
550
+ System.Nullable[int]
551
+ #>
552
+ [CmdletBinding()]
553
+ [OutputType([int])]
554
+ param([Parameter(Mandatory)][AllowEmptyString()][string] $CommandText)
555
+
556
+ $records = @(Read-CommandLineInvocationSegment -CommandText $CommandText)
557
+ if ($records.Count -ne 1) { return $null }
558
+ $record = $records[0]
559
+ if ($record.Origin -cne 'TopLevel' -or $record.Unbalanced -or $record.Opaque -or $record.Delimiter.Length -ne 0) { return $null }
560
+ if ($record.RawText.Trim() -cne (ConvertTo-CommandLineNormalizedText -CommandText $CommandText).Trim()) { return $null }
561
+ foreach ($scan in @((ConvertTo-EpicMergeGateReviewScanText -CommandText $CommandText), (ConvertTo-EpicMergeGateReviewScanText -CommandText $CommandText -RemoveBacktick))) {
562
+ if ([regex]::Matches($scan, $script:EpicMergeGateReviewMergePattern).Count -ne 1) { return $null }
563
+ }
564
+ if (Test-EpicMergeGateReviewApiTrigger -CommandText $CommandText) { return $null }
565
+ return Get-EpicMergeGateReviewBoundTokenNumber -Token ([string[]]@($record.Tokens))
566
+ }
567
+
568
+ function Get-EpicMergeGateReviewCandidatePrNumber {
569
+ <#
570
+ .SYNOPSIS
571
+ Return the distinct digit runs of command text that are not part of a longer word (pure).
572
+ .DESCRIPTION
573
+ A number the shape rule cannot bind may still name an item, because a parallel parent
574
+ issues a child's merge from its own root, so each run is a candidate for a checkpoint read.
575
+ .OUTPUTS
576
+ System.Int32[]
577
+ #>
578
+ [CmdletBinding()]
579
+ [OutputType([int[]])]
580
+ param([Parameter(Mandatory)][AllowEmptyString()][string] $CommandText)
581
+
582
+ $scan = ConvertTo-EpicMergeGateReviewScanText -CommandText $CommandText
583
+ $candidates = [System.Collections.Generic.List[int]]::new()
584
+ foreach ($match in [regex]::Matches($scan, '(?<![\w-])[0-9]+(?![\w-])')) {
585
+ $number = 0
586
+ if ([int]::TryParse($match.Value, [ref] $number) -and -not $candidates.Contains($number)) { $candidates.Add($number) }
587
+ }
588
+ return [int[]]$candidates.ToArray()
589
+ }
590
+
591
+ function Get-EpicMergeGateReviewShapeDecision {
592
+ <#
593
+ .SYNOPSIS
594
+ Deny text that could be a merge and cannot be bound to one pull request (shape rule).
595
+ .DESCRIPTION
596
+ A bound command passes with no read, so the guard then decides it. Otherwise the decision
597
+ denies when a known checkpoint, or any checkpoint the reader returns, carries the key, and
598
+ passes when none does. Without the key the decision is therefore unchanged, and a failed
599
+ read is best-effort: it sees no key and never denies. The reader is called for the session
600
+ ($null) only when KnownPrNumber is set, because a bare command already read it, and for each of
601
+ the first 4 candidate numbers other than KnownPrNumber, in text order. The Codex gate passes
602
+ no reader.
603
+ .PARAMETER CommandText
604
+ The raw command text.
605
+ .PARAMETER KnownCheckpoint
606
+ The parsed checkpoint the gate already read, or $null.
607
+ .PARAMETER KnownPrNumber
608
+ The number that checkpoint was read for, or $null for the session.
609
+ .PARAMETER ReadCheckpoint
610
+ Returns the parsed checkpoint of a number, or of the session for $null.
611
+ .OUTPUTS
612
+ System.Collections.Specialized.OrderedDictionary with Denied and Reason.
613
+ #>
614
+ [CmdletBinding()]
615
+ [OutputType([System.Collections.Specialized.OrderedDictionary])]
616
+ param(
617
+ [Parameter(Mandatory)][AllowEmptyString()][string] $CommandText,
618
+ [AllowNull()] $KnownCheckpoint,
619
+ [AllowNull()][Nullable[int]] $KnownPrNumber,
620
+ [scriptblock] $ReadCheckpoint
621
+ )
622
+
623
+ $pass = [ordered]@{ Denied = $false; Reason = $null }
624
+ if ($null -ne (Get-EpicMergeGateReviewBoundPrNumber -CommandText $CommandText)) { return $pass }
625
+
626
+ $deny = [ordered]@{ Denied = $true; Reason = 'EPIC_MERGE_GATE_BLOCKED: REVIEW_GATE_HEAD_UNRESOLVED: review_mode concurrent requires a merge that can be bound to one pull request. Issue gh pr merge <PR> --merge as its own Bash call, and remove the phrase from any other command.' }
627
+ if (Test-EpicMergeGateReviewModeConcurrent -Checkpoint $KnownCheckpoint) { return $deny }
628
+ if ($null -eq $ReadCheckpoint) { return $pass }
629
+
630
+ $targets = [System.Collections.Generic.List[object]]::new()
631
+ if ($null -ne $KnownPrNumber) { $targets.Add($null) }
632
+ $candidates = @(Get-EpicMergeGateReviewCandidatePrNumber -CommandText $CommandText | Where-Object { $_ -ne $KnownPrNumber })
633
+ foreach ($candidate in @($candidates | Select-Object -First $script:EpicMergeGateReviewCandidateCap)) { $targets.Add($candidate) }
634
+ foreach ($target in $targets) {
635
+ $checkpoint = $null
636
+ try {
637
+ $checkpoint = & $ReadCheckpoint $target
638
+ }
639
+ catch {
640
+ # Best-effort read: a failure sees no key, so a run without the key keeps its decision.
641
+ $checkpoint = $null
642
+ }
643
+ if (Test-EpicMergeGateReviewModeConcurrent -Checkpoint $checkpoint) { return $deny }
644
+ }
645
+ return $pass
646
+ }
647
+
648
+ function Read-EpicMergeGateReviewItemCheckpoint {
649
+ <#
650
+ .SYNOPSIS
651
+ Return the parsed checkpoint of a number, or of the session for $null (Claude only).
652
+ .DESCRIPTION
653
+ Runs at run time in the Claude gate, which holds the functions it calls. The read is
654
+ best-effort: an unresolved target and any failure of the resolver or the read return $null,
655
+ so a run without the key keeps its decision and a failure never denies.
656
+ .PARAMETER PrNumber
657
+ The pull request number, or $null for the session.
658
+ .OUTPUTS
659
+ System.Object or $null
660
+ #>
661
+ [CmdletBinding()]
662
+ param([AllowNull()][Nullable[int]] $PrNumber)
663
+
664
+ try {
665
+ $root = $null
666
+ if ($null -eq $PrNumber) {
667
+ $root = Get-EpicMergeGateSessionWorktreeRoot
668
+ }
669
+ else {
670
+ $target = Resolve-EpicMergeGateItemTarget -PrNumber $PrNumber
671
+ if (@('SessionRoot', 'OtherWorktree') -contains $target.Status) { $root = $target.WorktreeRoot }
672
+ }
673
+ if (-not $root) { return $null }
674
+ return ConvertFrom-EpicMergeGateJson -Raw (Get-ChildOrchestratorCheckpointContent -Path (Get-WorktreeRunCheckpointPath -Kind item -WorktreeRoot $root))
675
+ }
676
+ catch {
677
+ # Best-effort read: the caller treats $null as a checkpoint without the key.
678
+ return $null
679
+ }
680
+ }
681
+
682
+ function Get-EpicMergeGateReviewUnscopedDecision {
683
+ <#
684
+ .SYNOPSIS
685
+ Apply the shape rule to a command the scope predicate does not read (Claude only).
686
+ .DESCRIPTION
687
+ Reads through Read-EpicMergeGateReviewItemCheckpoint, so a command that today reaches the
688
+ out-of-scope allow can never fail on the gate's own target read.
689
+ .OUTPUTS
690
+ System.Collections.Specialized.OrderedDictionary with Denied and Reason.
691
+ #>
692
+ [CmdletBinding()]
693
+ [OutputType([System.Collections.Specialized.OrderedDictionary])]
694
+ param([Parameter(Mandatory)][AllowEmptyString()][string] $CommandText)
695
+
696
+ $number = Get-EpicMergeGateSelectorPrNumber -CommandText $CommandText
697
+ $known = Read-EpicMergeGateReviewItemCheckpoint -PrNumber $number
698
+ return Get-EpicMergeGateReviewShapeDecision -CommandText $CommandText -KnownCheckpoint $known -KnownPrNumber $number -ReadCheckpoint ${function:Read-EpicMergeGateReviewItemCheckpoint}
699
+ }