@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.
- package/out/mcp-server.js +3526 -1037
- package/package.json +1 -1
- package/resources/claude-customizations/.claude/agents/parallel-orchestrator.md +15 -2
- package/resources/claude-customizations/.claude/agents/parallel-planner.md +3 -0
- package/resources/claude-customizations/.claude/hooks/enforce-epic-merge-gate.ps1 +49 -14
- package/resources/claude-customizations/.claude/hooks/enforce-epic-worktree-removal-gate.ps1 +52 -3
- package/resources/claude-customizations/.claude/hooks/enforce-orchestration-preimplementation-gate.ps1 +7 -1
- package/resources/claude-customizations/.claude/hooks/enforce-parallel-abandon-gate.ps1 +103 -6
- package/resources/claude-customizations/.claude/hooks/enforce-parallel-worktree-removal-gate.ps1 +58 -3
- package/resources/claude-customizations/.claude/hooks/enforce-pr-author-skill-helpers.ps1 +37 -5
- package/resources/claude-customizations/.claude/hooks/enforce-pr-author-skill.epic-base-branch.ps1 +13 -2
- package/resources/claude-customizations/.claude/hooks/enforce-promotion-mcp-only.ps1 +35 -7
- package/resources/claude-customizations/.claude/hooks/hook-command-invocation.ps1 +483 -0
- package/resources/claude-customizations/.claude/hooks/hook-command-scanner.ps1 +483 -0
- package/resources/claude-customizations/.claude/hooks/validate-bash.ps1 +254 -5
- package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadius.psm1 +18 -75
- package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusConflict.psm1 +290 -0
- package/resources/claude-customizations/.claude/lib/cleanup-manifest/CleanupWorktreeManifest.psm1 +415 -0
- package/resources/claude-customizations/.claude/lib/project-file-merge/ProjectFileMerge.psm1 +355 -0
- package/resources/claude-customizations/.claude/lib/project-file-merge/ProjectFileMergeGrammar.psm1 +318 -0
- package/resources/claude-customizations/.claude/lib/project-file-merge/Resolve-MergeableConflict.ps1 +229 -0
- package/resources/claude-customizations/.claude/rules/parallel-orchestration.md +66 -2
- package/resources/claude-customizations/.claude/skills/cleanup-merged-worktrees/SKILL.md +311 -16
- package/resources/claude-customizations/.claude/skills/orchestrate/SKILL.md +42 -0
- package/resources/claude-customizations/.claude/skills/parallel-add/SKILL.md +3 -1
- package/resources/claude-customizations/.claude/skills/parallel-orchestrate/SKILL.md +36 -2
- package/resources/claude-customizations/.claude/skills/parallel-plan/SKILL.md +6 -0
- package/resources/claude-customizations/.claude/skills/powershell-orchestration-state-machine/SKILL.md +29 -1
- package/resources/claude-customizations/config/blast-radius.json +7 -0
- package/resources/claude-customizations/pack-manifests/core.json +7 -0
- package/resources/codex-and-agents-customizations/.agents/skills/orchestrate/SKILL.md +41 -0
- package/resources/codex-and-agents-customizations/.agents/skills/orchestrator-state/SKILL.md +41 -0
- package/resources/codex-and-agents-customizations/.agents/skills/repo-automation-adapter/SKILL.md +26 -0
- package/resources/codex-and-agents-customizations/.codex/config.toml +1 -1
- package/resources/codex-and-agents-customizations/.codex/hooks/enforce-epic-merge-gate.ps1 +49 -3
- package/resources/codex-and-agents-customizations/.codex/hooks/enforce-epic-planning-only.ps1 +57 -12
- package/resources/codex-and-agents-customizations/.codex/hooks/enforce-epic-worktree-removal-gate.ps1 +34 -8
- package/resources/codex-and-agents-customizations/.codex/hooks/enforce-orchestration-preimplementation-gate.ps1 +6 -1
- package/resources/codex-and-agents-customizations/.codex/hooks/enforce-promotion-mcp-only.ps1 +34 -7
- package/resources/codex-and-agents-customizations/.codex/hooks/hook-command-invocation.ps1 +483 -0
- package/resources/codex-and-agents-customizations/.codex/hooks/hook-command-scanner.ps1 +483 -0
- package/resources/codex-and-agents-customizations/.codex/hooks/validate-bash.ps1 +130 -2
- package/resources/codex-and-agents-customizations/pack-manifests/core.json +6 -1
- package/resources/config/orchestration-handoff-registry.json +138 -0
- package/resources/config/orchestration-handoff.schema.json +472 -0
- 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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
112
|
-
|
|
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
|
+
}
|