@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
@@ -39,6 +39,47 @@ On every invocation, the main session must:
39
39
  selected route's required agents, skills, and MCP tools into checkpoint
40
40
  state.
41
41
 
42
+ ## Prepared-State Portable Handoff Intake
43
+
44
+ Treat a portable prepared-state checkpoint as a resume request, not as a new
45
+ orchestration intake. Before delegation:
46
+
47
+ 1. Validate the portable envelope through the published, workspace-explicit
48
+ orchestration authority. Require the repository, workspace, branch lineage,
49
+ issue, feature folder, work mode, scheduler context, capabilities, and exact
50
+ next transition to match the current checkout. Supply the complete
51
+ caller-controlled independent expected context on every call to
52
+ `resolve_orchestration_topology`, `resolve_provider_routing`, and
53
+ `transition_prepared_orchestration`: `expected_repository_id`,
54
+ `expected_workspace_root`, `expected_branch`, `expected_source_head_sha`,
55
+ `allowed_head_relationship`, `expected_issue_number`,
56
+ `expected_feature_folder`, `expected_work_mode`, `expected_plan_path`, and
57
+ `expected_plan_sha256`. Derive each value from the destination checkout and
58
+ the caller's own record. A call that omits a value, or that copies one from
59
+ the envelope under validation, is rejected before any service invocation.
60
+ 2. Prove the plan from the independently expected repository-relative path and
61
+ raw-byte SHA-256 the caller supplied in `expected_plan_path` and
62
+ `expected_plan_sha256`. Do not search for another plan, rewrite the path,
63
+ follow a symlink outside the repository, substitute a newer plan, or read
64
+ the plan identity from the envelope.
65
+ 3. Use `transition_prepared_orchestration` as the only authority permitted to
66
+ materialize the provider-native destination checkpoint. A dry run may
67
+ validate and project the destination state but must not archive or replace
68
+ checkpoint bytes.
69
+ 4. Preserve the ordered completed phases and reject any request to replay one
70
+ of them. Do not rerun promotion, research, feature-document authoring,
71
+ atomic planning, or preflight when the envelope records them as complete.
72
+ 5. Preserve source-provider model, profile, topology, launch, and receipt data
73
+ as opaque historical evidence. Resolve Codex topology, model routing, and
74
+ launch evidence only for the first new destination delegation after
75
+ materialization; never synthesize Codex receipts for source-provider work.
76
+
77
+ For an ordinary handoff whose recorded next transition is `atomic_execution`,
78
+ resume the exact approved plan through the ordinary Codex atomic-executor path
79
+ at `atomic_execution`. Do not invoke a parent scheduler or rediscover lifecycle
80
+ work. Any contract, authority, plan, replay, or materialization failure returns
81
+ the deterministic blocked result before destination delegation.
82
+
42
83
  ## Epic Entry Boundary
43
84
 
44
85
  This standalone workflow must not invoke `epic-planner` or `epic-orchestrator`. If intake names
@@ -17,6 +17,47 @@ A hardened snapshot from another repository contains a JSON Schema for the orche
17
17
 
18
18
  This prohibition is specific to the disqualified foreign schema identified by the `drmoisan.github.io/mix-calculator/` `$id`. A schema whose `$id` is repo-local and whose required-field set and `additionalProperties` policy match this repository's checkpoint contract is not the disqualified foreign artifact; even so, the repository's enforcement mechanism remains the Python validator prose-and-logic above, not an imported schema file.
19
19
 
20
+ ## Portable Handoff Projection Invariants
21
+
22
+ A provider-native destination checkpoint materialized from the portable handoff
23
+ contract must retain the following linkage:
24
+
25
+ - `provider`, `checkpoint_expression`, and `destination_projector` identify the
26
+ destination expression and selected adapter.
27
+ - `plan-path` and `next_step` equal the portable envelope's exact plan path and
28
+ recorded lifecycle transition. The destination must not rediscover a plan or
29
+ replace either value from local convention.
30
+ - `portable_handoff` retains the handoff ID, envelope SHA-256, latest history
31
+ entry SHA-256, adapter identity, source validator, identity and binding
32
+ fields, source checkpoint/archive facts, exact plan proof, lifecycle,
33
+ capabilities, and scheduler context.
34
+ - Historical source receipts remain opaque references under
35
+ `portable_handoff.source.expression.historical_receipts`. They must not be
36
+ rewritten as destination-provider receipts.
37
+ - `destination_evidence` starts as `pending_first_delegation` with an empty
38
+ receipt list. Destination routing, topology, model, and receipts may be
39
+ recorded only for the first new delegation after checkpoint materialization.
40
+
41
+ The portable lifecycle permits only the registered state transitions:
42
+ `legacy_v1` to migration, `preparation_complete` to
43
+ `prepared_to_atomic_execution`, `validated` to destination materialization,
44
+ `materialized` to `atomic_execution`, and a bounded scheduled-child return from
45
+ an authorized child execution phase. An attempted replay of a completed phase
46
+ is invalid.
47
+
48
+ A failed contract, binding, capability, authority, plan, replay, dirty-worktree,
49
+ candidate, archive, or replacement check must produce the deterministic blocked
50
+ result and leave the source checkpoint authoritative. A blocked result must
51
+ retain the primary `HANDOFF_*` code and affected paths where applicable; it must
52
+ not record a completed transition or destination delegation.
53
+
54
+ For parallel and epic children, `return_to_scheduler` accepts only a result
55
+ whose run, item, parent checkpoint path/hash, scheduler owner, child execution
56
+ owner, return contract, plan hash, child checkpoint hash, and result hash match
57
+ the envelope. The child may return that bounded result but may not assume
58
+ cohort or wave ordering, barriers, fan-in, integration, cleanup, or parent
59
+ completion authority.
60
+
20
61
  ## Scope and Backward Compatibility
21
62
 
22
63
  These invariants apply only when the checkpoint contains a top-level `remediation_loop` with a `cycles` array. A checkpoint with no `remediation_loop` (the existing step-based checkpoint shape) is unaffected: it validates exactly as before and produces no new errors. The invariants are additive.
@@ -50,6 +50,32 @@ Use these semantic MCP tools when the corresponding operation is required:
50
50
  - `run_poshqc_analyze`
51
51
  - `run_poshqc_analyze_autofix`
52
52
  - `run_poshqc_test`
53
+ - `resolve_orchestration_topology`
54
+ - `resolve_provider_routing`
55
+ - `transition_prepared_orchestration`
56
+
57
+ ## Independent Expected Context for Portable Handoff Tools
58
+
59
+ The three portable prepared-orchestration tools bind a handoff to one specific
60
+ destination checkout. Every call to `resolve_orchestration_topology`,
61
+ `resolve_provider_routing`, and `transition_prepared_orchestration` must carry
62
+ the complete caller-controlled independent expected context:
63
+
64
+ - `expected_repository_id`
65
+ - `expected_workspace_root`
66
+ - `expected_branch`
67
+ - `expected_source_head_sha`
68
+ - `allowed_head_relationship`
69
+ - `expected_issue_number`
70
+ - `expected_feature_folder`
71
+ - `expected_work_mode`
72
+ - `expected_plan_path`
73
+ - `expected_plan_sha256`
74
+
75
+ Derive each value from the destination checkout and the caller's own record.
76
+ The envelope under validation is never the source of the values it is
77
+ validated against. A call that omits a value, or that copies one from the
78
+ envelope, is rejected before the service is invoked.
53
79
 
54
80
  Legacy VS Code command IDs are historical source material only and must not be invoked:
55
81
 
@@ -2,7 +2,7 @@ default_permissions = ":danger-full-access"
2
2
 
3
3
  [mcp_servers.drm-copilot]
4
4
  command = "npx"
5
- args = ["-y", "@danmoisan/drm-copilot-mcp@1.1.9"]
5
+ args = ["-y", "@danmoisan/drm-copilot-mcp@1.1.11"]
6
6
  required = true
7
7
  enabled_tools = [
8
8
  "collect_commit_context",
@@ -5,6 +5,12 @@
5
5
  [CmdletBinding()]
6
6
  param()
7
7
 
8
+ # Shared command-line parser (issue #545). The scope filter and the PR-number resolver below
9
+ # both run against the segment that structurally invokes `gh pr merge`, which is what keeps
10
+ # the two runtimes on one implementation of the same concern.
11
+ . (Join-Path $PSScriptRoot 'hook-command-scanner.ps1')
12
+ . (Join-Path $PSScriptRoot 'hook-command-invocation.ps1')
13
+
8
14
  function ConvertFrom-CodexMergeJson {
9
15
  [CmdletBinding()]
10
16
  param([AllowNull()][AllowEmptyString()][string] $Raw, [Parameter(Mandatory)][string] $Name, [switch] $Optional)
@@ -26,13 +32,37 @@ function ConvertFrom-CodexMergeJson {
26
32
  }
27
33
 
28
34
  function Get-CodexMergeCommandPrNumber {
35
+ <#
36
+ .SYNOPSIS
37
+ Resolve the explicit pull-request number of a gh pr merge invocation, or $null.
38
+ .DESCRIPTION
39
+ The number is taken from the segment that structurally invokes `gh pr merge` and
40
+ from nowhere else: the positional spelling through Get-CommandLineOperand, the
41
+ flag-led and equals-joined spellings through Get-CommandLineFlagValue. A leading
42
+ `cd <path>` segment contributes no operand and no flag value, so no digit run
43
+ outside the merge segment can be mistaken for a pull-request number.
44
+
45
+ This copy has never carried the Claude copy's unanchored whole-text digit scan and
46
+ deliberately does not acquire one. No regular expression matching a bare digit run
47
+ is introduced here; the only pattern below anchors an all-digit token end to end.
48
+ .OUTPUTS
49
+ System.Nullable[int]
50
+ #>
29
51
  [CmdletBinding()]
30
52
  [OutputType([int])]
31
53
  param([Parameter(Mandatory)][string] $Command)
32
54
 
33
- if ($Command -match '(?i)\bgh\s+pr\s+merge\s+(\d+)\b') {
34
- return [int]$Matches[1]
55
+ foreach ($operand in @(Get-CommandLineOperand -CommandText $Command -CommandWord 'gh' -SubcommandPath @('pr', 'merge'))) {
56
+ if ($operand -match '^\d+$') {
57
+ return [int]$operand
58
+ }
59
+ }
60
+
61
+ $flagValue = Get-CommandLineFlagValue -CommandText $Command -CommandWord 'gh' -SubcommandPath @('pr', 'merge') -FlagName '--merge'
62
+ if ($null -ne $flagValue -and $flagValue -match '^\d+$') {
63
+ return [int]$flagValue
35
64
  }
65
+
36
66
  return $null
37
67
  }
38
68
 
@@ -95,7 +125,23 @@ function Invoke-CodexEpicMergeDecision {
95
125
  return $null
96
126
  }
97
127
  $command = [string]$payload.tool_input.command
98
- if ($command -notmatch '(?i)\bgh\s+pr\s+merge\b' -or $command -notmatch '(?i)--merge\b') {
128
+ # Both legs are read structurally from the segment that invokes the command, so a
129
+ # quoted mention of the phrase no longer brings a command into scope. The flag leg needs
130
+ # a raw-scan fallback: a wrapper's quoted argument collapses into ONE token, so a flag
131
+ # inside it is never read as a token, and the token-only read took
132
+ # bash -c "gh pr merge --merge 688" out of scope.
133
+ $isMergeInvocation = Test-CommandLineInvocation -CommandText $command -CommandWord 'gh' -SubcommandPath @('pr', 'merge')
134
+ $hasMergeFlag = Test-CommandLineFlag -CommandText $command -CommandWord 'gh' -SubcommandPath @('pr', 'merge') -FlagName '--merge'
135
+ if (-not $hasMergeFlag) {
136
+ foreach ($segment in @(Read-CommandLineSegment -CommandText $command)) {
137
+ if ((Test-CommandLineSegmentRawScan -Segment $segment) -and
138
+ $segment.ScanText.IndexOf('--merge', [System.StringComparison]::OrdinalIgnoreCase) -ge 0) {
139
+ $hasMergeFlag = $true
140
+ break
141
+ }
142
+ }
143
+ }
144
+ if (-not $isMergeInvocation -or -not $hasMergeFlag) {
99
145
  return $null
100
146
  }
101
147
 
@@ -16,8 +16,13 @@ $script:AllowedPreparationMcpTools = @(
16
16
  'mcp__drm-copilot__potential_to_issue',
17
17
  'mcp__drm-copilot__new_active_feature_folder',
18
18
  'mcp__drm-copilot__resolve_atomic_plan_prompt',
19
- 'mcp__drm-copilot__resolve_execute_hard_lock_prompt',
20
- 'mcp__drm-copilot__validate_orchestration_artifacts'
19
+ 'mcp__drm-copilot__resolve_execute_hard_lock_prompt'
20
+ )
21
+ $script:PreparationSemanticMcpIds = @(
22
+ 'drm-copilot.validate_orchestration_artifacts',
23
+ 'drm-copilot.resolve_orchestration_topology',
24
+ 'drm-copilot.resolve_provider_routing',
25
+ 'drm-copilot.transition_prepared_orchestration'
21
26
  )
22
27
 
23
28
  function ConvertFrom-EpicPlanningJson {
@@ -41,6 +46,47 @@ function ConvertFrom-EpicPlanningJson {
41
46
  }
42
47
  }
43
48
 
49
+ function Get-EpicPlanningRegisteredMcpTool {
50
+ [CmdletBinding()]
51
+ [OutputType([string[]])]
52
+ param(
53
+ [Parameter(Mandatory)][string] $RegistryPath,
54
+ [Parameter(Mandatory)][string[]] $SemanticIds
55
+ )
56
+
57
+ if (-not (Test-Path -LiteralPath $RegistryPath -PathType Leaf)) {
58
+ throw "EPIC_PLANNING_ONLY_BLOCKED: semantic MCP registry '$RegistryPath' does not exist."
59
+ }
60
+ $registry = ConvertFrom-EpicPlanningJson `
61
+ -Raw (Get-Content -Raw -LiteralPath $RegistryPath) `
62
+ -Name 'semantic MCP registry'
63
+ $registered = [System.Collections.Generic.List[string]]::new()
64
+ foreach ($semanticId in $SemanticIds) {
65
+ $property = $registry.semantic_tools.PSObject.Properties[$semanticId]
66
+ if ($null -eq $property) {
67
+ throw "EPIC_PLANNING_ONLY_BLOCKED: semantic MCP id '$semanticId' is not registered."
68
+ }
69
+ $entry = $property.Value
70
+ $operation = $semanticId.Substring($semanticId.LastIndexOf('.') + 1)
71
+ if ([string]$entry.operation -ne $operation) {
72
+ throw "EPIC_PLANNING_ONLY_BLOCKED: semantic MCP id '$semanticId' has an invalid operation."
73
+ }
74
+ foreach ($alias in @($entry.transport_aliases)) {
75
+ $escapedOperation = [regex]::Escape($operation)
76
+ if ([string]$alias -notmatch "^mcp__(?:drm-copilot|drm_copilot)__$escapedOperation`$") {
77
+ throw "EPIC_PLANNING_ONLY_BLOCKED: semantic MCP id '$semanticId' has an invalid transport alias."
78
+ }
79
+ $registered.Add([string]$alias)
80
+ }
81
+ }
82
+ return [string[]]($registered | Sort-Object -Unique)
83
+ }
84
+
85
+ $script:EpicPlanningRepositoryRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
86
+ $script:AllowedPreparationSemanticMcpTools = @(Get-EpicPlanningRegisteredMcpTool `
87
+ -RegistryPath (Join-Path $script:EpicPlanningRepositoryRoot 'config/orchestration-handoff-registry.json') `
88
+ -SemanticIds $script:PreparationSemanticMcpIds)
89
+
44
90
  function Get-EpicPlanningDenyDecision {
45
91
  [CmdletBinding()]
46
92
  [OutputType([System.Collections.Specialized.OrderedDictionary])]
@@ -223,11 +269,9 @@ function Invoke-EpicPlanningOnlyDecision {
223
269
  return Get-EpicPlanningDenyDecision -Reason 'the Bash command is outside the preparation read, branch, commit, push, or validator allowlist.'
224
270
  }
225
271
 
226
- if ($toolName -like 'mcp__*' -and $toolName -notlike 'mcp__drm-copilot__*') {
227
- return Get-EpicPlanningDenyDecision -Reason "MCP tool '$toolName' is not part of the repository-scoped preparation surface."
228
- }
229
-
230
- if ($toolName -like 'mcp__drm-copilot__*') {
272
+ $allowedRepositoryMcp = $script:AllowedPreparationMcpTools -contains $toolName
273
+ $allowedSemanticMcp = $script:AllowedPreparationSemanticMcpTools -contains $toolName
274
+ if ($allowedRepositoryMcp -or $allowedSemanticMcp) {
231
275
  if ($attestedPreparation) {
232
276
  $workspaceRoot = [string]$payload.tool_input.workspace_root
233
277
  if ([string]::IsNullOrWhiteSpace($workspaceRoot) -or
@@ -236,10 +280,11 @@ function Invoke-EpicPlanningOnlyDecision {
236
280
  return Get-EpicPlanningDenyDecision -Reason 'preparation MCP calls require workspace_root equal to the attested session cwd.'
237
281
  }
238
282
  }
239
- if ($script:AllowedPreparationMcpTools -contains $toolName) {
240
- return $null
241
- }
242
- return Get-EpicPlanningDenyDecision -Reason "MCP tool '$toolName' is outside the preparation lifecycle and validator allowlist."
283
+ return $null
284
+ }
285
+
286
+ if ($toolName -like 'mcp__*') {
287
+ return Get-EpicPlanningDenyDecision -Reason "MCP tool '$toolName' is outside the registered preparation lifecycle and semantic allowlist."
243
288
  }
244
289
 
245
290
  return Get-EpicPlanningDenyDecision -Reason "tool '$toolName' is not classified for preparation mode."
@@ -273,7 +318,7 @@ if ($MyInvocation.InvocationName -eq '.') {
273
318
 
274
319
  try {
275
320
  $payloadRaw = [Console]::In.ReadToEnd()
276
- $repositoryRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
321
+ $repositoryRoot = $script:EpicPlanningRepositoryRoot
277
322
  $checkpointPath = Join-Path $repositoryRoot 'artifacts/orchestration/orchestrator-state.json'
278
323
  $checkpointRaw = if (Test-Path -LiteralPath $checkpointPath -PathType Leaf) {
279
324
  Get-Content -Raw -LiteralPath $checkpointPath
@@ -5,6 +5,12 @@
5
5
  [CmdletBinding()]
6
6
  param()
7
7
 
8
+ # Shared command-line parser (issue #545). The scope filter and the operand extractor below
9
+ # both run against the segment that structurally invokes `git worktree remove`, which is what
10
+ # keeps the two runtimes on one implementation of the same concern.
11
+ . (Join-Path $PSScriptRoot 'hook-command-scanner.ps1')
12
+ . (Join-Path $PSScriptRoot 'hook-command-invocation.ps1')
13
+
8
14
  $script:SafeWorktreeStatuses = @('merged', 'worktree_removed')
9
15
 
10
16
  function ConvertFrom-CodexWorktreeJson {
@@ -28,18 +34,35 @@ function ConvertFrom-CodexWorktreeJson {
28
34
  }
29
35
 
30
36
  function Get-CodexWorktreeRemovalPath {
37
+ <#
38
+ .SYNOPSIS
39
+ Extract the target worktree path from a git worktree remove command.
40
+ .DESCRIPTION
41
+ The operand comes from the segment that structurally invokes git worktree remove, so
42
+ a chained 'cd <path> &&' segment contributes nothing and a quoted mention of the
43
+ phrase resolves to no operand. The tokenizer strips balanced double and single
44
+ quotes, which is what the previous pattern's `double` and `single` alternatives did.
45
+
46
+ The previous pattern accepted '--force' only immediately after 'remove'. That
47
+ spelling is preserved and the trailing spelling now works too, because '--force' is a
48
+ zero-argument flag that never contributes an operand wherever it is written. Its
49
+ presence is read structurally through Test-CommandLineFlag rather than by a raw-text
50
+ search. The empty-string-on-miss contract is unchanged: callers test `if ($target)`.
51
+ .OUTPUTS
52
+ System.String
53
+ #>
31
54
  [CmdletBinding()]
32
55
  [OutputType([string])]
33
56
  param([Parameter(Mandatory)][AllowEmptyString()][string] $Command)
34
57
 
35
- $pattern = '(?i)\bgit\s+worktree\s+remove(?:\s+--force)?\s+(?:"(?<double>[^"]+)"|''(?<single>[^'']+)''|(?<bare>\S+))'
36
- if ($Command -notmatch $pattern) {
37
- return ''
58
+ $hasForce = Test-CommandLineFlag -CommandText $Command -CommandWord 'git' -SubcommandPath @('worktree', 'remove') -FlagName '--force'
59
+ $operands = @(Get-CommandLineOperand -CommandText $Command -CommandWord 'git' -SubcommandPath @('worktree', 'remove'))
60
+
61
+ if ($operands.Count -gt 0) {
62
+ return [string]$operands[0]
38
63
  }
39
- foreach ($name in @('double', 'single', 'bare')) {
40
- if ($Matches[$name]) {
41
- return [string]$Matches[$name]
42
- }
64
+ if ($hasForce) {
65
+ return '--force'
43
66
  }
44
67
  return ''
45
68
  }
@@ -97,7 +120,10 @@ function Invoke-CodexWorktreeRemovalDecision {
97
120
  return $null
98
121
  }
99
122
  $command = [string]$payload.tool_input.command
100
- if ($command -notmatch '(?i)\bgit\s+worktree\s+remove\b') {
123
+ # Scope filter. Structural, so a relocating spelling such as
124
+ # 'git -C <dir> worktree remove <path>' is in scope and quoted prose that merely mentions
125
+ # the phrase is not (issue #545).
126
+ if (-not (Test-CommandLineInvocation -CommandText $command -CommandWord 'git' -SubcommandPath @('worktree', 'remove'))) {
101
127
  return $null
102
128
  }
103
129
  $target = Get-CodexWorktreeRemovalPath -Command $command
@@ -20,6 +20,9 @@ param()
20
20
  # normative contract and which lacks headroom; leaving that file byte-untouched is the
21
21
  # proof the issue #539 exemption is behaviourally unchanged.
22
22
  . (Join-Path $PSScriptRoot 'enforce-orchestration-preimplementation-gate-modes.ps1')
23
+ # Shared command-line parser (issue #545): per-segment scan text and structural matching.
24
+ . (Join-Path $PSScriptRoot 'hook-command-scanner.ps1')
25
+ . (Join-Path $PSScriptRoot 'hook-command-invocation.ps1')
23
26
 
24
27
  # The readiness checkpoint this gate reads and names in its block message.
25
28
  $script:CheckpointPath = 'artifacts/orchestration/orchestrator-state.json'
@@ -151,9 +154,11 @@ function Test-ImplementationCommand {
151
154
  '(^|\s)npx\s+(prettier|eslint|tsc|jest)\b',
152
155
  '(^|\s)pwsh\s+.*(Invoke-Pester|tests/scripts/)'
153
156
  )
157
+ $scanText = @(Read-CommandLineSegment -CommandText $normalizedCommand).ScanText
158
+ $isStaging = @('add', 'commit').Where({ Test-CommandLineInvocation -CommandText $normalizedCommand -CommandWord 'git' -SubcommandPath @($_) }).Count -gt 0
154
159
 
155
160
  for ($index = 0; $index -lt $implementationCommandPatterns.Count; $index++) {
156
- if ($normalizedCommand -notmatch $implementationCommandPatterns[$index]) {
161
+ if (-not (($scanText | Where-Object { $_ -match $implementationCommandPatterns[$index] }) -or ($index -eq 0 -and $isStaging))) {
157
162
  continue
158
163
  }
159
164
  # Allow-side only (issue #539). Index 0 is the git staging trigger, whose pattern
@@ -29,6 +29,12 @@
29
29
  [CmdletBinding()]
30
30
  param()
31
31
 
32
+ # Shared command-line parser (issue #545). Every forbidden-token scan and both `gh`
33
+ # expressions below run against each segment's clause-ordered scan text, and the
34
+ # structural matcher reaches the relocating `gh` spellings adjacency cannot.
35
+ . (Join-Path $PSScriptRoot 'hook-command-scanner.ps1')
36
+ . (Join-Path $PSScriptRoot 'hook-command-invocation.ps1')
37
+
32
38
  $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.'
33
39
 
34
40
  $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.'
@@ -88,15 +94,34 @@ function Get-PromotionBypassReason {
88
94
  'new_active_feature_folder'
89
95
  )
90
96
 
91
- foreach ($token in $forbiddenTokens) {
92
- if ($CommandText.IndexOf($token, [System.StringComparison]::OrdinalIgnoreCase) -ge 0) {
93
- return (Get-PromotionMcpOnlyBlockedReason)
97
+ # Issue #545: the scan text of a segment is its raw text only when the segment is
98
+ # wrapper-led, carries a live substitution, or could not be balanced; otherwise
99
+ # quoted spans and attached heredoc bodies are masked. A promotion tool name that
100
+ # appears only as a JSON receipt value is therefore a mention, not an invocation.
101
+ $segments = @(Read-CommandLineSegment -CommandText $CommandText)
102
+
103
+ foreach ($segment in $segments) {
104
+ foreach ($token in $forbiddenTokens) {
105
+ if ($segment.ScanText.IndexOf($token, [System.StringComparison]::OrdinalIgnoreCase) -ge 0) {
106
+ return (Get-PromotionMcpOnlyBlockedReason)
107
+ }
94
108
  }
95
109
  }
96
110
 
97
111
  # `gh issue create` and `gh issue new` are direct bypasses of the MCP
98
112
  # promotion path. Tolerate any flags after the subcommand.
99
- if ($CommandText -match '(?i)\bgh\s+issue\s+(?:create|new)\b') {
113
+ foreach ($segment in $segments) {
114
+ if ($segment.ScanText -match '(?i)\bgh\s+issue\s+(?:create|new)\b') {
115
+ return (Get-PromotionMcpOnlyGhIssueBlockedReason)
116
+ }
117
+ }
118
+
119
+ # The adjacency-requiring expression above cannot see a relocating spelling, in
120
+ # which a `gh` global option such as --repo or -R sits between the command word
121
+ # and its subcommand (D10). The structural matcher absorbs those options and
122
+ # reads the subcommand path positionally, so both spellings classify.
123
+ if ((Test-CommandLineInvocation -CommandText $CommandText -CommandWord 'gh' -SubcommandPath @('issue', 'create')) -or
124
+ (Test-CommandLineInvocation -CommandText $CommandText -CommandWord 'gh' -SubcommandPath @('issue', 'new'))) {
100
125
  return (Get-PromotionMcpOnlyGhIssueBlockedReason)
101
126
  }
102
127
 
@@ -104,10 +129,12 @@ function Get-PromotionBypassReason {
104
129
  # explicit POST method is supplied. `gh api` defaults to GET, so we only
105
130
  # block when -X POST or --method POST is present, to avoid false positives
106
131
  # on issue read operations. Use a single regex with lookaheads against the
107
- # whole command string.
132
+ # scan text of one segment.
108
133
  $ghApiIssuesPostPattern = '(?i)(?=.*\bgh\s+api\b)(?=.*repos/[^/\s]+/[^/\s]+/issues(?:\b|/[^/\s]*$))(?=.*(?:-X\s+POST|--method\s+POST))'
109
- if ($CommandText -match $ghApiIssuesPostPattern) {
110
- return (Get-PromotionMcpOnlyGhIssueBlockedReason)
134
+ foreach ($segment in $segments) {
135
+ if ($segment.ScanText -match $ghApiIssuesPostPattern) {
136
+ return (Get-PromotionMcpOnlyGhIssueBlockedReason)
137
+ }
111
138
  }
112
139
 
113
140
  return $null