@danmoisan/drm-copilot-mcp 1.1.9 → 1.1.11

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 (46) hide show
  1. package/out/mcp-server.js +3526 -1037
  2. package/package.json +1 -1
  3. package/resources/claude-customizations/.claude/agents/parallel-orchestrator.md +15 -2
  4. package/resources/claude-customizations/.claude/agents/parallel-planner.md +3 -0
  5. package/resources/claude-customizations/.claude/hooks/enforce-epic-merge-gate.ps1 +49 -14
  6. package/resources/claude-customizations/.claude/hooks/enforce-epic-worktree-removal-gate.ps1 +52 -3
  7. package/resources/claude-customizations/.claude/hooks/enforce-orchestration-preimplementation-gate.ps1 +7 -1
  8. package/resources/claude-customizations/.claude/hooks/enforce-parallel-abandon-gate.ps1 +103 -6
  9. package/resources/claude-customizations/.claude/hooks/enforce-parallel-worktree-removal-gate.ps1 +58 -3
  10. package/resources/claude-customizations/.claude/hooks/enforce-pr-author-skill-helpers.ps1 +37 -5
  11. package/resources/claude-customizations/.claude/hooks/enforce-pr-author-skill.epic-base-branch.ps1 +13 -2
  12. package/resources/claude-customizations/.claude/hooks/enforce-promotion-mcp-only.ps1 +35 -7
  13. package/resources/claude-customizations/.claude/hooks/hook-command-invocation.ps1 +483 -0
  14. package/resources/claude-customizations/.claude/hooks/hook-command-scanner.ps1 +483 -0
  15. package/resources/claude-customizations/.claude/hooks/validate-bash.ps1 +254 -5
  16. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadius.psm1 +18 -75
  17. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusConflict.psm1 +290 -0
  18. package/resources/claude-customizations/.claude/lib/cleanup-manifest/CleanupWorktreeManifest.psm1 +415 -0
  19. package/resources/claude-customizations/.claude/lib/project-file-merge/ProjectFileMerge.psm1 +355 -0
  20. package/resources/claude-customizations/.claude/lib/project-file-merge/ProjectFileMergeGrammar.psm1 +318 -0
  21. package/resources/claude-customizations/.claude/lib/project-file-merge/Resolve-MergeableConflict.ps1 +229 -0
  22. package/resources/claude-customizations/.claude/rules/parallel-orchestration.md +66 -2
  23. package/resources/claude-customizations/.claude/skills/cleanup-merged-worktrees/SKILL.md +311 -16
  24. package/resources/claude-customizations/.claude/skills/orchestrate/SKILL.md +42 -0
  25. package/resources/claude-customizations/.claude/skills/parallel-add/SKILL.md +3 -1
  26. package/resources/claude-customizations/.claude/skills/parallel-orchestrate/SKILL.md +36 -2
  27. package/resources/claude-customizations/.claude/skills/parallel-plan/SKILL.md +6 -0
  28. package/resources/claude-customizations/.claude/skills/powershell-orchestration-state-machine/SKILL.md +29 -1
  29. package/resources/claude-customizations/config/blast-radius.json +7 -0
  30. package/resources/claude-customizations/pack-manifests/core.json +7 -0
  31. package/resources/codex-and-agents-customizations/.agents/skills/orchestrate/SKILL.md +41 -0
  32. package/resources/codex-and-agents-customizations/.agents/skills/orchestrator-state/SKILL.md +41 -0
  33. package/resources/codex-and-agents-customizations/.agents/skills/repo-automation-adapter/SKILL.md +26 -0
  34. package/resources/codex-and-agents-customizations/.codex/config.toml +1 -1
  35. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-epic-merge-gate.ps1 +49 -3
  36. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-epic-planning-only.ps1 +57 -12
  37. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-epic-worktree-removal-gate.ps1 +34 -8
  38. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-orchestration-preimplementation-gate.ps1 +6 -1
  39. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-promotion-mcp-only.ps1 +34 -7
  40. package/resources/codex-and-agents-customizations/.codex/hooks/hook-command-invocation.ps1 +483 -0
  41. package/resources/codex-and-agents-customizations/.codex/hooks/hook-command-scanner.ps1 +483 -0
  42. package/resources/codex-and-agents-customizations/.codex/hooks/validate-bash.ps1 +130 -2
  43. package/resources/codex-and-agents-customizations/pack-manifests/core.json +6 -1
  44. package/resources/config/orchestration-handoff-registry.json +138 -0
  45. package/resources/config/orchestration-handoff.schema.json +472 -0
  46. package/resources/powershell/PoshQC/settings/pester.runsettings.psd1 +38 -0
@@ -31,6 +31,13 @@ param()
31
31
 
32
32
 
33
33
  Import-Module (Join-Path $PSScriptRoot '../lib/hook-payload/HookPayload.psm1') -Force
34
+
35
+ # Shared command-line parser (issue #545). Every forbidden-token scan and both `gh`
36
+ # expressions below run against each segment's clause-ordered scan text, and the
37
+ # structural matcher reaches the relocating `gh` spellings adjacency cannot.
38
+ . (Join-Path $PSScriptRoot 'hook-command-scanner.ps1')
39
+ . (Join-Path $PSScriptRoot 'hook-command-invocation.ps1')
40
+
34
41
  $script:PromotionMcpOnlyBlockedReason = 'PROMOTION_MCP_ONLY_BLOCKED: Direct Bash promotion-script execution is not allowed in agent sessions. Use the drm-copilot MCP promotion tools instead.'
35
42
 
36
43
  $script:PromotionMcpOnlyGhIssueBlockedReason = 'PROMOTION_MCP_ONLY_BLOCKED: Direct GitHub issue creation via `gh` bypasses the approved drm-copilot MCP promotion path (`mcp__drm-copilot__new_potential_entry` -> `mcp__drm-copilot__potential_to_issue` -> `mcp__drm-copilot__new_active_feature_folder`). Use those MCP tools instead.'
@@ -90,15 +97,34 @@ function Get-PromotionBypassReason {
90
97
  'new_active_feature_folder'
91
98
  )
92
99
 
93
- foreach ($token in $forbiddenTokens) {
94
- if ($CommandText.IndexOf($token, [System.StringComparison]::OrdinalIgnoreCase) -ge 0) {
95
- return (Get-PromotionMcpOnlyBlockedReason)
100
+ # Issue #545: the scan text of a segment is its raw text only when the segment is
101
+ # wrapper-led, carries a live substitution, or could not be balanced; otherwise
102
+ # quoted spans and attached heredoc bodies are masked. A promotion tool name that
103
+ # appears only as a JSON receipt value is therefore a mention, not an invocation.
104
+ $segments = @(Read-CommandLineSegment -CommandText $CommandText)
105
+
106
+ foreach ($segment in $segments) {
107
+ foreach ($token in $forbiddenTokens) {
108
+ if ($segment.ScanText.IndexOf($token, [System.StringComparison]::OrdinalIgnoreCase) -ge 0) {
109
+ return (Get-PromotionMcpOnlyBlockedReason)
110
+ }
96
111
  }
97
112
  }
98
113
 
99
114
  # `gh issue create` and `gh issue new` are direct bypasses of the MCP
100
115
  # promotion path. Tolerate any flags after the subcommand.
101
- if ($CommandText -match '(?i)\bgh\s+issue\s+(?:create|new)\b') {
116
+ foreach ($segment in $segments) {
117
+ if ($segment.ScanText -match '(?i)\bgh\s+issue\s+(?:create|new)\b') {
118
+ return (Get-PromotionMcpOnlyGhIssueBlockedReason)
119
+ }
120
+ }
121
+
122
+ # The adjacency-requiring expression above cannot see a relocating spelling, in
123
+ # which a `gh` global option such as --repo or -R sits between the command word
124
+ # and its subcommand (D10). The structural matcher absorbs those options and
125
+ # reads the subcommand path positionally, so both spellings classify.
126
+ if ((Test-CommandLineInvocation -CommandText $CommandText -CommandWord 'gh' -SubcommandPath @('issue', 'create')) -or
127
+ (Test-CommandLineInvocation -CommandText $CommandText -CommandWord 'gh' -SubcommandPath @('issue', 'new'))) {
102
128
  return (Get-PromotionMcpOnlyGhIssueBlockedReason)
103
129
  }
104
130
 
@@ -106,10 +132,12 @@ function Get-PromotionBypassReason {
106
132
  # explicit POST method is supplied. `gh api` defaults to GET, so we only
107
133
  # block when -X POST or --method POST is present, to avoid false positives
108
134
  # on issue read operations. Use a single regex with lookaheads against the
109
- # whole command string.
135
+ # scan text of one segment.
110
136
  $ghApiIssuesPostPattern = '(?i)(?=.*\bgh\s+api\b)(?=.*repos/[^/\s]+/[^/\s]+/issues(?:\b|/[^/\s]*$))(?=.*(?:-X\s+POST|--method\s+POST))'
111
- if ($CommandText -match $ghApiIssuesPostPattern) {
112
- return (Get-PromotionMcpOnlyGhIssueBlockedReason)
137
+ foreach ($segment in $segments) {
138
+ if ($segment.ScanText -match $ghApiIssuesPostPattern) {
139
+ return (Get-PromotionMcpOnlyGhIssueBlockedReason)
140
+ }
113
141
  }
114
142
 
115
143
  return $null
@@ -0,0 +1,483 @@
1
+ <#
2
+ .SYNOPSIS
3
+ Structural command-invocation matcher shared by the Claude and Codex enforcement hooks.
4
+ .DESCRIPTION
5
+ Realizes Piece 3 of the D2 behavior contract and the retrieval surface of D12 in
6
+ docs/features/active/2026-08-25-enforcement-hook-trigger-matches-whole-command-text-545/spec.md.
7
+
8
+ Answers, per segment produced by hook-command-scanner.ps1, whether a command line
9
+ INVOKES a named command word with a named subcommand path, rather than merely
10
+ containing those words adjacently. It also retrieves the operands and flag values of
11
+ the matched invocation, taken from the matched segment only.
12
+
13
+ Pure string logic only: no disk, process, network, clock, or environment access. It is
14
+ dot-sourced as: . (Join-Path $PSScriptRoot 'hook-command-invocation.ps1')
15
+ #>
16
+
17
+ . (Join-Path $PSScriptRoot 'hook-command-scanner.ps1')
18
+
19
+ # The transparent-wrapper set of D2 Piece 3 step 2. These prefix a command without changing
20
+ # which command runs, so the structural matcher skips them. All five are also members of the
21
+ # scanner's wrapper carve-out set, deliberately, so 'env git worktree remove x' classifies by
22
+ # both mechanisms. Pinned by test through Get-CommandLineTransparentWrapperName (rule R6).
23
+ $script:CommandLineTransparentWrapperNames = @('command', 'env', 'nohup', 'time', 'timeout')
24
+
25
+ # The modeled global-option tables of D2 Piece 3 steps 3 and the D6/D10 gh surface. An option
26
+ # in WithArgument consumes the following token as its value, or carries the value inline in
27
+ # the '--name=value' form; an option in Standalone consumes only itself. A dash-leading token
28
+ # in neither list is UNMODELED, and an unmodeled token between the command word and the
29
+ # subcommand classifies as a match: over-classification only forces a checkpoint check,
30
+ # whereas under-classification is a bypass. Pinned by test through
31
+ # Get-CommandLineGlobalOption (rule R6).
32
+ $script:CommandLineGlobalOptions = @{
33
+ git = [pscustomobject]@{
34
+ WithArgument = @('-C', '-c', '--git-dir', '--work-tree', '--namespace', '--exec-path')
35
+ Standalone = @('-p', '--paginate', '--no-pager', '--literal-pathspecs', '--no-optional-locks', '--bare')
36
+ }
37
+ gh = [pscustomobject]@{
38
+ WithArgument = @('-R', '--repo')
39
+ Standalone = @()
40
+ }
41
+ npx = [pscustomobject]@{
42
+ WithArgument = @('-p', '--package', '-c', '--call', '--node-arg', '--userconfig')
43
+ Standalone = @('-y', '--yes', '--no-install', '--ignore-existing', '-q', '--quiet')
44
+ }
45
+ }
46
+
47
+ # Zero-argument flags whose arity is known at operand-collection time regardless of command
48
+ # word. Without '--force' here, 'git worktree remove --force <path>' would terminate operand
49
+ # collection at the flag and return no path, which is exactly the acceptance case AT-7
50
+ # requires to resolve. Every member takes no value, so consuming one token is always correct.
51
+ $script:CommandLineStandaloneFlagNames = @('--force', '-f', '--dry-run', '-n', '--quiet', '-q', '--verbose', '-v')
52
+
53
+ function Get-CommandLineTransparentWrapperName {
54
+ <#
55
+ .SYNOPSIS
56
+ Return the transparent-wrapper set of D2 Piece 3 step 2.
57
+ .OUTPUTS
58
+ System.String[]
59
+ #>
60
+ [CmdletBinding()]
61
+ [OutputType([string[]])]
62
+ param()
63
+
64
+ return [string[]]$script:CommandLineTransparentWrapperNames
65
+ }
66
+
67
+ function Get-CommandLineGlobalOption {
68
+ <#
69
+ .SYNOPSIS
70
+ Return the modeled global-option table for one command word.
71
+ .DESCRIPTION
72
+ Returns a record carrying two string arrays: WithArgument, whose members consume a
73
+ following token or an inline '=value', and Standalone, whose members consume only
74
+ themselves. An unrecognized command word yields a record with two empty arrays, so
75
+ every dash-leading token for that command word is treated as unmodeled and therefore
76
+ classifies - the fail-closed direction.
77
+ .OUTPUTS
78
+ System.Management.Automation.PSCustomObject
79
+ #>
80
+ [CmdletBinding()]
81
+ [OutputType([pscustomobject])]
82
+ param([Parameter(Mandatory)][ValidateNotNullOrEmpty()][string] $CommandWord)
83
+
84
+ $key = $CommandWord.ToLowerInvariant()
85
+ if ($script:CommandLineGlobalOptions.ContainsKey($key)) {
86
+ return $script:CommandLineGlobalOptions[$key]
87
+ }
88
+
89
+ return [pscustomobject]@{ WithArgument = @(); Standalone = @() }
90
+ }
91
+
92
+ function Test-CommandLineRawContainment {
93
+ <#
94
+ .SYNOPSIS
95
+ Report whether a raw text contains the command word and every subcommand element.
96
+ .DESCRIPTION
97
+ The "in any arrangement" test D12 specifies for a wrapper-led or live-substitution
98
+ segment. Comparison is ordinal case-insensitive containment, which is deliberately
99
+ loose: a false positive only forces a checkpoint check, a false negative is a bypass.
100
+ .OUTPUTS
101
+ System.Boolean
102
+ #>
103
+ [CmdletBinding()]
104
+ [OutputType([bool])]
105
+ param(
106
+ [Parameter(Mandatory)][AllowEmptyString()][string] $RawText,
107
+ [Parameter(Mandatory)][string] $CommandWord,
108
+ [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string[]] $SubcommandPath
109
+ )
110
+
111
+ $comparison = [System.StringComparison]::OrdinalIgnoreCase
112
+ if ($RawText.IndexOf($CommandWord, $comparison) -lt 0) { return $false }
113
+ foreach ($element in $SubcommandPath) {
114
+ if ($RawText.IndexOf($element, $comparison) -lt 0) { return $false }
115
+ }
116
+ return $true
117
+ }
118
+
119
+ function Skip-CommandLineOption {
120
+ <#
121
+ .SYNOPSIS
122
+ Absorb modeled options from a token list and report where they end.
123
+ .DESCRIPTION
124
+ Returns Index, the position of the first token that is not a modeled option, and
125
+ Unmodeled, set when a dash-leading token appears in neither list of the supplied
126
+ table. A bare '-' and a bare '--' are not options and stop the absorption.
127
+ .OUTPUTS
128
+ System.Management.Automation.PSCustomObject
129
+ #>
130
+ [CmdletBinding()]
131
+ [OutputType([pscustomobject])]
132
+ param(
133
+ [Parameter(Mandatory)][AllowEmptyCollection()][AllowEmptyString()][string[]] $Token,
134
+ [Parameter(Mandatory)][int] $StartIndex,
135
+ [Parameter(Mandatory)][pscustomobject] $Option
136
+ )
137
+
138
+ $index = $StartIndex
139
+ while ($index -lt $Token.Count) {
140
+ $current = $Token[$index]
141
+ if (-not $current.StartsWith('-') -or $current -eq '-' -or $current -eq '--') { break }
142
+
143
+ $name = $current
144
+ $hasInlineValue = $false
145
+ $split = $current.IndexOf('=')
146
+ if ($split -gt 0) {
147
+ $name = $current.Substring(0, $split)
148
+ $hasInlineValue = $true
149
+ }
150
+
151
+ if ($Option.WithArgument -contains $name) {
152
+ $index += if ($hasInlineValue) { 1 } else { 2 }
153
+ continue
154
+ }
155
+ if ($Option.Standalone -contains $name) {
156
+ $index++
157
+ continue
158
+ }
159
+
160
+ return [pscustomobject]@{ Index = $index; Unmodeled = $true }
161
+ }
162
+
163
+ return [pscustomobject]@{ Index = $index; Unmodeled = $false }
164
+ }
165
+
166
+ function Resolve-CommandLineInvocation {
167
+ <#
168
+ .SYNOPSIS
169
+ Locate the first segment that invokes a command word with a subcommand path.
170
+ .DESCRIPTION
171
+ Implements the four mandatory fail-closed rules of D12 in order, per segment:
172
+ an Unbalanced segment classifies because its structure could not be resolved; a
173
+ wrapper-led or live-substitution segment classifies when its RawText contains the
174
+ command word and every subcommand element in any arrangement; an unmodeled
175
+ dash-leading token between the command word and the subcommand classifies; and a
176
+ non-dash token that is not the next expected subcommand element terminates that
177
+ segment's scan without a match, so 'git log --grep add' does not classify.
178
+
179
+ Returns $null when no segment matched. Otherwise returns Segment, the matched record,
180
+ and OperandIndex, the token position just past the subcommand path - or -1 when the
181
+ match was reached by a non-structural rule, in which case there is no operand
182
+ position to report.
183
+ .OUTPUTS
184
+ System.Management.Automation.PSCustomObject
185
+ #>
186
+ [CmdletBinding()]
187
+ [OutputType([pscustomobject])]
188
+ param(
189
+ [Parameter(Mandatory)][AllowEmptyString()][AllowNull()][string] $CommandText,
190
+ [Parameter(Mandatory)][string] $CommandWord,
191
+ [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string[]] $SubcommandPath
192
+ )
193
+
194
+ $option = Get-CommandLineGlobalOption -CommandWord $CommandWord
195
+ $transparent = $script:CommandLineTransparentWrapperNames
196
+
197
+ foreach ($segment in @(Read-CommandLineSegment -CommandText $CommandText)) {
198
+ if ($segment.Unbalanced) {
199
+ return [pscustomobject]@{ Segment = $segment; OperandIndex = -1 }
200
+ }
201
+
202
+ if (($segment.IsWrapperLed -or $segment.HasLiveSubstitution) -and
203
+ (Test-CommandLineRawContainment -RawText $segment.RawText -CommandWord $CommandWord -SubcommandPath $SubcommandPath)) {
204
+ return [pscustomobject]@{ Segment = $segment; OperandIndex = -1 }
205
+ }
206
+
207
+ $tokens = @($segment.Tokens)
208
+ $index = 0
209
+ while ($index -lt $tokens.Count -and (
210
+ $tokens[$index] -match '^[A-Za-z_][A-Za-z0-9_]*=' -or
211
+ ($transparent -contains $tokens[$index] -and $tokens[$index] -ne $CommandWord))) {
212
+ $index++
213
+ }
214
+
215
+ if ($index -ge $tokens.Count -or $tokens[$index] -ne $CommandWord) { continue }
216
+ $index++
217
+
218
+ $matched = $true
219
+ foreach ($element in $SubcommandPath) {
220
+ $skip = Skip-CommandLineOption -Token $tokens -StartIndex $index -Option $option
221
+ if ($skip.Unmodeled) {
222
+ return [pscustomobject]@{ Segment = $segment; OperandIndex = -1 }
223
+ }
224
+ $index = $skip.Index
225
+ if ($index -ge $tokens.Count -or $tokens[$index] -ne $element) {
226
+ $matched = $false
227
+ break
228
+ }
229
+ $index++
230
+ }
231
+
232
+ if ($matched) {
233
+ return [pscustomobject]@{ Segment = $segment; OperandIndex = $index }
234
+ }
235
+ }
236
+
237
+ return $null
238
+ }
239
+
240
+ function Test-CommandLineInvocation {
241
+ <#
242
+ .SYNOPSIS
243
+ Report whether a command line invokes a named command word with a named
244
+ subcommand path, structurally rather than by raw-text adjacency.
245
+ .DESCRIPTION
246
+ Scans every segment produced by Read-CommandLineSegment and applies the four
247
+ mandatory fail-closed rules of D12, which are stated in full on
248
+ Resolve-CommandLineInvocation. This predicate is the replacement for every
249
+ raw-text trigger regex that asked whether a governed command runs.
250
+ .PARAMETER CommandText
251
+ The raw Bash command text.
252
+ .PARAMETER CommandWord
253
+ The command name, e.g. 'git' or 'gh'. Compared case-insensitively.
254
+ .PARAMETER SubcommandPath
255
+ One or more ordered subcommand tokens, e.g. @('worktree','remove'), @('pr','merge'),
256
+ @('issue','create'), @('add'). Compared case-insensitively.
257
+ .OUTPUTS
258
+ System.Boolean
259
+ #>
260
+ [CmdletBinding()]
261
+ [OutputType([bool])]
262
+ param(
263
+ [Parameter(Mandatory)][AllowEmptyString()][AllowNull()][string] $CommandText,
264
+ [Parameter(Mandatory)][string] $CommandWord,
265
+ [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string[]] $SubcommandPath
266
+ )
267
+
268
+ return $null -ne (Resolve-CommandLineInvocation -CommandText $CommandText -CommandWord $CommandWord -SubcommandPath $SubcommandPath)
269
+ }
270
+
271
+ function Get-CommandLineOperand {
272
+ <#
273
+ .SYNOPSIS
274
+ Return the positional operands of a matched invocation, in source order.
275
+ .DESCRIPTION
276
+ Locates the first segment in which CommandWord + SubcommandPath match STRUCTURALLY,
277
+ then returns the non-option tokens that follow the subcommand path in that segment
278
+ only. Modeled option-with-argument pairs are consumed, so '-C <arg>' consumes both
279
+ tokens; known zero-argument flags such as '--force' contribute nothing; a token after
280
+ a bare '--' separator is always an operand; and a dash-leading token that is not
281
+ modeled terminates collection, because its arity is unknown and a wrong guess would
282
+ silently return a flag as a path.
283
+
284
+ The operand list comes from the MATCHED segment only, so a 'cd <path>' segment chained
285
+ before the invocation contributes nothing. That is the direct fix for issue #591. A
286
+ match reached by a non-structural fail-closed rule reports no operand position, and
287
+ yields no operands.
288
+
289
+ Callers receive the result through the @(...) array form used at every call site, so
290
+ the no-operand case arrives as an empty array rather than as $null.
291
+ .PARAMETER CommandText
292
+ The raw Bash command text.
293
+ .PARAMETER CommandWord
294
+ The command name.
295
+ .PARAMETER SubcommandPath
296
+ The ordered subcommand tokens.
297
+ .OUTPUTS
298
+ System.String[] - operands with balanced quotes already stripped, in source order.
299
+ #>
300
+ [CmdletBinding()]
301
+ [OutputType([string[]])]
302
+ param(
303
+ [Parameter(Mandatory)][AllowEmptyString()][AllowNull()][string] $CommandText,
304
+ [Parameter(Mandatory)][string] $CommandWord,
305
+ [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string[]] $SubcommandPath
306
+ )
307
+
308
+ $operands = [System.Collections.Generic.List[string]]::new()
309
+ $resolved = Resolve-CommandLineInvocation -CommandText $CommandText -CommandWord $CommandWord -SubcommandPath $SubcommandPath
310
+ if ($null -eq $resolved -or $resolved.OperandIndex -lt 0) {
311
+ return [string[]]$operands.ToArray()
312
+ }
313
+
314
+ $option = Get-CommandLineGlobalOption -CommandWord $CommandWord
315
+ $tokens = @($resolved.Segment.Tokens)
316
+ $index = $resolved.OperandIndex
317
+ $afterSeparator = $false
318
+
319
+ while ($index -lt $tokens.Count) {
320
+ $current = $tokens[$index]
321
+
322
+ if ($afterSeparator) {
323
+ $operands.Add($current)
324
+ $index++
325
+ continue
326
+ }
327
+ if ($current -eq '--') {
328
+ $afterSeparator = $true
329
+ $index++
330
+ continue
331
+ }
332
+
333
+ if ($current.StartsWith('-') -and $current.Length -gt 1) {
334
+ $name = $current
335
+ $hasInlineValue = $false
336
+ $split = $current.IndexOf('=')
337
+ if ($split -gt 0) {
338
+ $name = $current.Substring(0, $split)
339
+ $hasInlineValue = $true
340
+ }
341
+
342
+ if ($option.WithArgument -contains $name) {
343
+ $index += if ($hasInlineValue) { 1 } else { 2 }
344
+ continue
345
+ }
346
+ if ($option.Standalone -contains $name -or $script:CommandLineStandaloneFlagNames -contains $name) {
347
+ $index++
348
+ continue
349
+ }
350
+ break
351
+ }
352
+
353
+ $operands.Add($current)
354
+ $index++
355
+ }
356
+
357
+ return [string[]]$operands.ToArray()
358
+ }
359
+
360
+ function Get-CommandLineFlagValue {
361
+ <#
362
+ .SYNOPSIS
363
+ Return the value of a named flag belonging to a matched invocation.
364
+ .DESCRIPTION
365
+ Locates the matched segment as Test-CommandLineInvocation does, then searches that
366
+ segment's tokens for FlagName. Recognizes both the separated form ('--merge 688') and
367
+ the equals form ('--merge=688'). Returns $null when the flag is absent, when the flag
368
+ is present with no following value token, and when the following token is itself
369
+ dash-leading.
370
+
371
+ The $null-on-missing-value contract is mandatory and is pinned by issue #591's first
372
+ constraint: downstream logic treats a missing explicit pull-request number as a
373
+ fail-closed condition, so a bare 'gh pr merge --merge' must yield $null, never 0 and
374
+ never ''.
375
+ .PARAMETER CommandText
376
+ The raw Bash command text.
377
+ .PARAMETER CommandWord
378
+ The command name.
379
+ .PARAMETER SubcommandPath
380
+ The ordered subcommand tokens.
381
+ .PARAMETER FlagName
382
+ The flag as written, including leading dashes, e.g. '--merge' or '--body-file'.
383
+ Compared case-insensitively.
384
+ .OUTPUTS
385
+ System.String or $null. Quotes are already stripped. Callers that need an integer cast
386
+ the result themselves after a $null check.
387
+ #>
388
+ [CmdletBinding()]
389
+ [OutputType([string])]
390
+ param(
391
+ [Parameter(Mandatory)][AllowEmptyString()][AllowNull()][string] $CommandText,
392
+ [Parameter(Mandatory)][string] $CommandWord,
393
+ [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string[]] $SubcommandPath,
394
+ [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string] $FlagName
395
+ )
396
+
397
+ $resolved = Resolve-CommandLineInvocation -CommandText $CommandText -CommandWord $CommandWord -SubcommandPath $SubcommandPath
398
+ if ($null -eq $resolved) { return $null }
399
+
400
+ $tokens = @($resolved.Segment.Tokens)
401
+ $prefix = $FlagName + '='
402
+ for ($index = 0; $index -lt $tokens.Count; $index++) {
403
+ $current = $tokens[$index]
404
+
405
+ if ($current -eq $FlagName) {
406
+ if ($index + 1 -ge $tokens.Count) { return $null }
407
+ $value = $tokens[$index + 1]
408
+ if ($value.StartsWith('-') -and $value.Length -gt 1) { return $null }
409
+ return $value
410
+ }
411
+
412
+ if ($current.StartsWith($prefix, [System.StringComparison]::OrdinalIgnoreCase)) {
413
+ $value = $current.Substring($prefix.Length)
414
+ if ([string]::IsNullOrEmpty($value)) { return $null }
415
+ return $value
416
+ }
417
+ }
418
+
419
+ return $null
420
+ }
421
+
422
+ function Test-CommandLineFlag {
423
+ <#
424
+ .SYNOPSIS
425
+ Report whether a named flag is present on a matched invocation, regardless of whether
426
+ it carries a value.
427
+ .DESCRIPTION
428
+ Presence-only companion to Get-CommandLineFlagValue, for boolean flags such as
429
+ '--merge', '--force', and '--body'. Matches both '--flag' and '--flag=value'. Exact
430
+ token comparison after quote stripping: '--body' does not match '--body-file', which
431
+ is the distinction the pr-author hook's negative lookahead currently expresses as
432
+ '--body(?!-file)\b'.
433
+ .OUTPUTS
434
+ System.Boolean
435
+ #>
436
+ [CmdletBinding()]
437
+ [OutputType([bool])]
438
+ param(
439
+ [Parameter(Mandatory)][AllowEmptyString()][AllowNull()][string] $CommandText,
440
+ [Parameter(Mandatory)][string] $CommandWord,
441
+ [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string[]] $SubcommandPath,
442
+ [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string] $FlagName
443
+ )
444
+
445
+ $resolved = Resolve-CommandLineInvocation -CommandText $CommandText -CommandWord $CommandWord -SubcommandPath $SubcommandPath
446
+ if ($null -eq $resolved) { return $false }
447
+
448
+ $prefix = $FlagName + '='
449
+ foreach ($current in @($resolved.Segment.Tokens)) {
450
+ if ($current -eq $FlagName) { return $true }
451
+ if ($current.StartsWith($prefix, [System.StringComparison]::OrdinalIgnoreCase)) { return $true }
452
+ }
453
+
454
+ return $false
455
+ }
456
+
457
+ function Test-CommandLineMention {
458
+ <#
459
+ .SYNOPSIS
460
+ Report whether a command line merely MENTIONS a command word and subcommand path
461
+ without invoking it.
462
+ .DESCRIPTION
463
+ Convenience inverse used by hooks that want to log or explain an allow. True when the
464
+ raw text contains the command word and every subcommand element but
465
+ Test-CommandLineInvocation returns false. Purely informational; no hook makes a deny
466
+ decision from this predicate.
467
+ .OUTPUTS
468
+ System.Boolean
469
+ #>
470
+ [CmdletBinding()]
471
+ [OutputType([bool])]
472
+ param(
473
+ [Parameter(Mandatory)][AllowEmptyString()][AllowNull()][string] $CommandText,
474
+ [Parameter(Mandatory)][string] $CommandWord,
475
+ [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string[]] $SubcommandPath
476
+ )
477
+
478
+ if (Test-CommandLineInvocation -CommandText $CommandText -CommandWord $CommandWord -SubcommandPath $SubcommandPath) {
479
+ return $false
480
+ }
481
+
482
+ return (Test-CommandLineRawContainment -RawText ([string]$CommandText) -CommandWord $CommandWord -SubcommandPath $SubcommandPath)
483
+ }