@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danmoisan/drm-copilot-mcp",
3
- "version": "1.1.9",
3
+ "version": "1.1.11",
4
4
  "description": "Stdio MCP server exposing drm-copilot repo-automation tools.",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",
@@ -18,6 +18,10 @@ tools:
18
18
  - "Bash(bash .claude/lib/bash/compute-cohorts.sh*)"
19
19
  - "Bash(bash .claude/lib/bash/compute-concurrency-batches.sh*)"
20
20
  - "Bash(bash .claude/lib/bash/validate-parallel-manifest.sh*)"
21
+ - "Bash(pwsh -NoProfile -File .claude/lib/project-file-merge/Resolve-MergeableConflict.ps1*)"
22
+ - "Bash(dotnet tool restore*)"
23
+ - "Bash(dotnet csharpier check *)"
24
+ - "Bash(dotnet build *)"
21
25
  - "mcp__drm-copilot__collect_pr_context"
22
26
  - "mcp__drm-copilot__validate_orchestration_artifacts"
23
27
  skills:
@@ -222,12 +226,21 @@ and `mcp_call_receipts[]` populated with the `parallel` route's required names f
222
226
 
223
227
  The checkpoint schema is owned by F3 and is defined once in
224
228
  `.claude/rules/parallel-orchestration.md`, enforced by
225
- `scripts/dev_tools/validate_parallel_orchestrator_state.py`. You consume that schema and add no
226
- field to it. The `parallel-orchestrate` skill's `## Parallel-Level Checkpoint` section carries the
229
+ `scripts/dev_tools/validate_parallel_orchestrator_state.py`. You consume that schema and
230
+ add no field to it that the rule file does not declare.
231
+ `mergeable_conflicts_resolved` is the one declared optional item field.
232
+ The `parallel-orchestrate` skill's `## Parallel-Level Checkpoint` section carries the
227
233
  full enumeration, the `merge_status` transition chain you write, and the arrays that are read-only
228
234
  to you. Validate through `mcp__drm-copilot__validate_orchestration_artifacts` with
229
235
  `artifact_type: "parallel-orchestrator-state"`.
230
236
 
237
+ You run the project-file merge step of the `parallel-orchestrate` skill yourself rather than
238
+ delegating it. The four entries added to your `tools:` list above for the resolution script and
239
+ for the three .NET verification commands are scoped to that step and to no other, and they are the
240
+ only reason those commands appear in your allowlist at all. That step's one optional item field is
241
+ `mergeable_conflicts_resolved`, declared in the rule file and appended to an item after a
242
+ parent-side resolution.
243
+
231
244
  ## Documentation Maintenance
232
245
 
233
246
  Maintain `docs/features/parallel/<slug>/parallel-status.md` as a human-readable projection of the
@@ -160,6 +160,9 @@ The facade exports `Get-PlanPaths`, `Get-BlastRadius`, `Get-BlastRadiusFromObser
160
160
  `Test-BlastRadius`, and `Test-BlastRadiusConflict`. Its truth table is
161
161
  `config/blast-radius.json`, which push-down publishes alongside `.claude`.
162
162
 
163
+ `Test-BlastRadiusConflict` reads that table's optional `mergeable_paths` list and contributes no
164
+ `path_overlap` edge for a path matching it, while the path stays in the declared radius.
165
+
163
166
  **Cohort seeding and concurrency batching — bash entry points.** The bash library is granted as
164
167
  four entry-point-specific allowlist entries — `"Bash(bash .claude/lib/bash/compute-cohorts.sh*)"`,
165
168
  `"Bash(bash .claude/lib/bash/compute-concurrency-batches.sh*)"`,
@@ -40,6 +40,10 @@
40
40
  param()
41
41
 
42
42
  Import-Module (Join-Path $PSScriptRoot '../lib/hook-payload/HookPayload.psm1') -Force
43
+ # Shared command-line parser (issue #545), consumed by the scope filter in
44
+ # Invoke-EpicMergeGateDecision and by Get-EpicMergeGateCommandPrNumber.
45
+ . (Join-Path $PSScriptRoot 'hook-command-scanner.ps1')
46
+ . (Join-Path $PSScriptRoot 'hook-command-invocation.ps1')
43
47
 
44
48
  $script:ChildCheckpointPath = 'artifacts/orchestration/orchestrator-state.json'
45
49
  $script:EpicCheckpointPath = 'artifacts/orchestration/epic-orchestrator-state.json'
@@ -128,6 +132,22 @@ function Get-EpicMergeGateCommandPrNumber {
128
132
  <#
129
133
  .SYNOPSIS
130
134
  Extract an explicit PR number argument from a gh pr merge command, or $null.
135
+ .DESCRIPTION
136
+ Both spellings are read from the segment that structurally invokes gh pr merge,
137
+ never from the whole command line. The positional form ("gh pr merge 410 --merge")
138
+ resolves through Get-CommandLineOperand and takes the first all-digit operand; the
139
+ flag-led forms ("gh pr merge --merge 410" and "gh pr merge --merge=410") resolve
140
+ through Get-CommandLineFlagValue. A bare "gh pr merge --merge" yields $null, which
141
+ the parallel branch treats as fail-closed.
142
+
143
+ The deleted unanchored branch scanned the WHOLE command text for the first run of
144
+ digits once "gh pr merge" appeared anywhere in it, and that failed in both
145
+ directions. Fail-closed: a leading "cd <path>" whose path carries a timestamp
146
+ component supplied a digit run that was not a pull request number, so an authorized
147
+ merge was blocked. False-allow: with authorized item 501 and unauthorized item 777,
148
+ "cd /repo/worktrees/501 && gh pr merge --merge 777" extracted 501, matched the
149
+ authorized item, and permitted the merge of PR 777. Taking the number from the
150
+ matched segment's own operand or flag value closes both directions.
131
151
  .PARAMETER CommandText
132
152
  The Bash command text under evaluation.
133
153
  .OUTPUTS
@@ -140,20 +160,17 @@ function Get-EpicMergeGateCommandPrNumber {
140
160
  [string] $CommandText
141
161
  )
142
162
 
143
- # Original form: the PR number appears immediately after "merge"
144
- # (e.g. "gh pr merge 410 --merge"). Preserved verbatim so epic-path outcomes
145
- # for the forms the epic path uses are unchanged.
146
- if ($CommandText -match '(?i)\bgh\s+pr\s+merge\s+(\d+)\b') {
147
- return [int]$Matches[1]
163
+ foreach ($operand in @(Get-CommandLineOperand -CommandText $CommandText -CommandWord 'gh' -SubcommandPath @('pr', 'merge'))) {
164
+ if ($operand -match '^\d+$') {
165
+ return [int]$operand
166
+ }
148
167
  }
149
- # Broadened, additive form: the parallel command places the flag before the
150
- # number (e.g. "gh pr merge --merge 410"). Once "gh pr merge" is confirmed,
151
- # capture the first standalone run of digits that is not preceded by "-" or a
152
- # word character, so a flag token such as "--merge" is not treated as a number
153
- # and a bare "gh pr merge --merge" still yields $null.
154
- if ($CommandText -match '(?i)\bgh\s+pr\s+merge\b' -and $CommandText -match '(?<![-\w])(\d+)\b') {
155
- return [int]$Matches[1]
168
+
169
+ $flagValue = Get-CommandLineFlagValue -CommandText $CommandText -CommandWord 'gh' -SubcommandPath @('pr', 'merge') -FlagName '--merge'
170
+ if ($null -ne $flagValue -and $flagValue -match '^\d+$') {
171
+ return [int]$flagValue
156
172
  }
173
+
157
174
  return $null
158
175
  }
159
176
 
@@ -373,8 +390,26 @@ function Invoke-EpicMergeGateDecision {
373
390
  }
374
391
 
375
392
  # Only a gh pr merge invocation carrying --merge is in scope for this gate; every
376
- # other Bash command is unaffected.
377
- if ($commandText -notmatch '(?i)\bgh\s+pr\s+merge\b' -or $commandText -notmatch '--merge\b') {
393
+ # other Bash command is unaffected. Both legs are read structurally from the segment
394
+ # that invokes the command, so a quoted mention of the phrase and a --merge token
395
+ # belonging to some other segment no longer bring a command into scope.
396
+ #
397
+ # The flag leg needs a raw-scan fallback: a wrapper's quoted argument collapses into ONE
398
+ # token, so a flag inside it is never read as a token, and the token-only read took
399
+ # bash -c "gh pr merge --merge 688" out of scope. The fallback reads only the segments the
400
+ # scanner already scans raw, so a masked mention in a non-wrapper segment stays out.
401
+ $isMergeInvocation = Test-CommandLineInvocation -CommandText $commandText -CommandWord 'gh' -SubcommandPath @('pr', 'merge')
402
+ $hasMergeFlag = Test-CommandLineFlag -CommandText $commandText -CommandWord 'gh' -SubcommandPath @('pr', 'merge') -FlagName '--merge'
403
+ if (-not $hasMergeFlag) {
404
+ foreach ($segment in @(Read-CommandLineSegment -CommandText $commandText)) {
405
+ if ((Test-CommandLineSegmentRawScan -Segment $segment) -and
406
+ $segment.ScanText.IndexOf('--merge', [System.StringComparison]::OrdinalIgnoreCase) -ge 0) {
407
+ $hasMergeFlag = $true
408
+ break
409
+ }
410
+ }
411
+ }
412
+ if (-not $isMergeInvocation -or -not $hasMergeFlag) {
378
413
  return Get-EpicMergeGateAllowDecision
379
414
  }
380
415
 
@@ -60,9 +60,20 @@ param()
60
60
 
61
61
 
62
62
  Import-Module (Join-Path $PSScriptRoot '../lib/hook-payload/HookPayload.psm1') -Force
63
+ # Sanctioned-removal manifest reader (issue #635), consumed by the manifest branch in
64
+ # Invoke-EpicWorktreeRemovalGateDecision.
65
+ Import-Module (Join-Path $PSScriptRoot '../lib/cleanup-manifest/CleanupWorktreeManifest.psm1') -Force
66
+ # Shared command-line parser (issue #545), consumed by the scope filter in
67
+ # Invoke-EpicWorktreeRemovalGateDecision and by Get-EpicWorktreeRemovalCommandPath.
68
+ . (Join-Path $PSScriptRoot 'hook-command-scanner.ps1')
69
+ . (Join-Path $PSScriptRoot 'hook-command-invocation.ps1')
63
70
  $script:EpicCheckpointPath = 'artifacts/orchestration/epic-orchestrator-state.json'
64
71
  $script:ParallelCheckpointPath = 'artifacts/orchestration/parallel-orchestrator-state.json'
65
72
  $script:AllowedMergeStatuses = @('merged', 'worktree_removed')
73
+ # Sanctioned-removal manifest location. Recorded here beside the two checkpoint paths
74
+ # so every hook-read document this gate consults is named in one place; the module
75
+ # owns the read itself.
76
+ $script:CleanupWorktreeManifestPath = 'artifacts/orchestration/cleanup-worktrees-manifest.json'
66
77
 
67
78
  function Get-EpicWorktreeGateCheckpointContent {
68
79
  <#
@@ -132,6 +143,19 @@ function Get-EpicWorktreeRemovalCommandPath {
132
143
  <#
133
144
  .SYNOPSIS
134
145
  Extract the target worktree path argument from a git worktree remove command.
146
+ .DESCRIPTION
147
+ The operand comes from the segment that structurally invokes git worktree remove,
148
+ so a 'cd <path> &&' segment chained before the removal contributes nothing and a
149
+ quoted mention of the phrase resolves to no operand at all. Quotes around the path
150
+ are already stripped by the tokenizer.
151
+
152
+ '--force' is a zero-argument flag, so it never contributes an operand and may be
153
+ written on either side of the target path. Its presence is read structurally through
154
+ Test-CommandLineFlag rather than by searching the raw text, so a '--force' spelling
155
+ that appears inside an unrelated quoted argument cannot change how the operand list
156
+ is read. When no operand resolves, a present '--force' is reported in its place, so
157
+ the checkpoint lookup fails closed on a value that matches no recorded worktree_path
158
+ - the same value the previous raw-text pattern returned for that input.
135
159
  .PARAMETER CommandText
136
160
  The Bash command text under evaluation.
137
161
  .OUTPUTS
@@ -144,8 +168,14 @@ function Get-EpicWorktreeRemovalCommandPath {
144
168
  [string] $CommandText
145
169
  )
146
170
 
147
- if ($CommandText -match '(?i)\bgit\s+worktree\s+remove\s+(?<path>\S+)') {
148
- return $Matches['path'].Trim('"''')
171
+ $hasForce = Test-CommandLineFlag -CommandText $CommandText -CommandWord 'git' -SubcommandPath @('worktree', 'remove') -FlagName '--force'
172
+ $operands = @(Get-CommandLineOperand -CommandText $CommandText -CommandWord 'git' -SubcommandPath @('worktree', 'remove'))
173
+
174
+ if ($operands.Count -gt 0) {
175
+ return $operands[0]
176
+ }
177
+ if ($hasForce) {
178
+ return '--force'
149
179
  }
150
180
  return $null
151
181
  }
@@ -344,7 +374,10 @@ function Invoke-EpicWorktreeRemovalGateDecision {
344
374
  return Get-EpicWorktreeGateAllowDecision
345
375
  }
346
376
 
347
- if ($commandText -notmatch '(?i)\bgit\s+worktree\s+remove\b') {
377
+ # Scope filter. The test is structural, so a relocating spelling such as
378
+ # 'git -C <dir> worktree remove <path>' is now in scope and quoted prose that merely
379
+ # mentions the phrase is not (issue #545).
380
+ if (-not (Test-CommandLineInvocation -CommandText $commandText -CommandWord 'git' -SubcommandPath @('worktree', 'remove'))) {
348
381
  return Get-EpicWorktreeGateAllowDecision
349
382
  }
350
383
 
@@ -362,6 +395,22 @@ function Invoke-EpicWorktreeRemovalGateDecision {
362
395
  return Get-EpicWorktreeGateAllowDecision
363
396
  }
364
397
 
398
+ # Sanctioned-removal manifest branch (issue #635). It runs last, so an
399
+ # epic-checkpoint-authorized or parallel-checkpoint-authorized removal still allows
400
+ # at the same decision point it does today and no transcript attribution changes.
401
+ #
402
+ # The two coverage tests are PRESENCE tests, deliberately not authorization tests.
403
+ # A target either checkpoint records at all is excluded from this branch regardless
404
+ # of that record's merge_status, so a removal no checkpoint authorizes still reaches
405
+ # the unchanged deny below. That exclusion is the only path by which the manifest
406
+ # could widen what the gates protect, which is why it is required rather than
407
+ # advisory, and why it is evaluated before the manifest predicate.
408
+ if (-not (Test-CleanupManifestCheckpointCoversPath -Checkpoint $checkpoint -RecordArrayName 'features' -WorktreePath $worktreePath) -and
409
+ -not (Test-CleanupManifestCheckpointCoversPath -Checkpoint $parallelCheckpoint -RecordArrayName 'items' -WorktreePath $worktreePath) -and
410
+ (Test-CleanupWorktreeManifestAuthorizesRemoval -WorktreePath $worktreePath)) {
411
+ return Get-EpicWorktreeGateAllowDecision
412
+ }
413
+
365
414
  return Get-EpicWorktreeGateBlockDecision -Reason "EPIC_WORKTREE_REMOVAL_BLOCKED: git worktree remove for '$worktreePath' requires either an epic checkpoint features[] record with merge_status in {merged, worktree_removed}, or a parallel-orchestrator checkpoint with route_id == ""parallel"" whose matching items[] record (matched by worktree_path) has merge_status in {merged, worktree_removed}. No checkpoint authorized this removal."
366
415
  }
367
416
 
@@ -19,6 +19,10 @@ Import-Module (Join-Path $PSScriptRoot '../lib/hook-payload/HookPayload.psm1') -
19
19
  # proof the issue #539 exemption is behaviourally unchanged.
20
20
  . (Join-Path $PSScriptRoot 'enforce-orchestration-preimplementation-gate-modes.ps1')
21
21
 
22
+ # Shared command-line parser (issue #545): per-segment scan text and structural matching.
23
+ . (Join-Path $PSScriptRoot 'hook-command-scanner.ps1')
24
+ . (Join-Path $PSScriptRoot 'hook-command-invocation.ps1')
25
+
22
26
  # The readiness checkpoint this gate reads and names in its block message.
23
27
  $script:CheckpointPath = 'artifacts/orchestration/orchestrator-state.json'
24
28
 
@@ -132,9 +136,11 @@ function Test-ImplementationCommand {
132
136
  '(^|\s)npx\s+(prettier|eslint|tsc|jest)\b',
133
137
  '(^|\s)pwsh\s+.*(Invoke-Pester|tests/scripts/)'
134
138
  )
139
+ $scanText = @(Read-CommandLineSegment -CommandText $normalizedCommand).ScanText
140
+ $isStaging = @('add', 'commit').Where({ Test-CommandLineInvocation -CommandText $normalizedCommand -CommandWord 'git' -SubcommandPath @($_) }).Count -gt 0
135
141
 
136
142
  for ($index = 0; $index -lt $implementationCommandPatterns.Count; $index++) {
137
- if ($normalizedCommand -notmatch $implementationCommandPatterns[$index]) {
143
+ if (-not (($scanText | Where-Object { $_ -match $implementationCommandPatterns[$index] }) -or ($index -eq 0 -and $isStaging))) {
138
144
  continue
139
145
  }
140
146
  # Allow-side only (issue #539). Index 0 is the git staging trigger, whose pattern
@@ -44,6 +44,13 @@ $script:AbandonConfirmToken = '--confirm-abandon'
44
44
  # Literal prefix on the deny reason, so the reason code is greppable in transcripts.
45
45
  $script:AbandonBlockedReasonCode = 'PARALLEL_ABANDON_BLOCKED'
46
46
 
47
+ # Shared command-line parser (issue #545). Dot-sourced BELOW the two token assignments
48
+ # rather than above them, deliberately: the assignments keep their existing line numbers,
49
+ # so the diff shows no change at either line. Both are still declared before any function
50
+ # body runs, because the file executes top to bottom before any of its functions is called.
51
+ . (Join-Path $PSScriptRoot 'hook-command-scanner.ps1')
52
+ . (Join-Path $PSScriptRoot 'hook-command-invocation.ps1')
53
+
47
54
  function Get-ParallelAbandonGateToolInput {
48
55
  <#
49
56
  .SYNOPSIS
@@ -85,10 +92,77 @@ function Get-ParallelAbandonNormalizedCommand {
85
92
  return ($CommandText -replace '\s+', ' ').Trim()
86
93
  }
87
94
 
95
+ function Test-ParallelAbandonSegmentDisposition {
96
+ <#
97
+ .SYNOPSIS
98
+ Report whether one segment's tokens request the abandon disposition.
99
+ .DESCRIPTION
100
+ The option name and its value are derived from $script:AbandonDispositionToken by
101
+ splitting it on whitespace, so this function restates neither literal and the seam
102
+ test's single-source-of-truth property is preserved. Both spellings the producer's
103
+ argparse registration accepts are recognized: the separated pair
104
+ '--disposition' 'abandon' as two adjacent tokens, and the equals-joined single token
105
+ '--disposition=abandon'.
106
+
107
+ Comparison is by whole-token equality, which is case-insensitive in PowerShell and
108
+ therefore preserves the OrdinalIgnoreCase behaviour of the containment test it
109
+ replaces. Because a quoted span is one token, a grep whose quoted search term is the
110
+ disposition token no longer requests an abandon.
111
+
112
+ A raw-scan leg runs after the token loop, on the segments the scanner itself scans
113
+ raw, so a disposition carried inside a wrapper's quoted argument is still recognized.
114
+ .PARAMETER Segment
115
+ One segment record produced by Read-CommandLineSegment.
116
+ .OUTPUTS
117
+ System.Boolean
118
+ #>
119
+ [CmdletBinding()]
120
+ [OutputType([bool])]
121
+ param([Parameter(Mandatory)][AllowNull()] $Segment)
122
+
123
+ $parts = @($script:AbandonDispositionToken -split '\s+' | Where-Object { $_ })
124
+ if ($parts.Count -lt 2) {
125
+ return $false
126
+ }
127
+ $optionName = $parts[0]
128
+ $optionValue = $parts[1]
129
+ $joined = $optionName + '=' + $optionValue
130
+
131
+ $token = @($Segment.Tokens)
132
+
133
+ for ($index = 0; $index -lt $token.Count; $index++) {
134
+ if ($token[$index] -eq $joined) {
135
+ return $true
136
+ }
137
+ if ($token[$index] -eq $optionName -and
138
+ $index + 1 -lt $token.Count -and
139
+ $token[$index + 1] -eq $optionValue) {
140
+ return $true
141
+ }
142
+ }
143
+
144
+ if (-not (Test-CommandLineSegmentRawScan -Segment $Segment)) {
145
+ return $false
146
+ }
147
+
148
+ # A wrapper's quoted argument is a nested command line and collapses into ONE token, so
149
+ # neither the adjacent pair nor the equals-joined token can form inside it. Both accepted
150
+ # spellings are reconstructed from the split constant rather than restated, so the
151
+ # single-source-of-truth seam test still finds each literal exactly once in this file.
152
+ $scanText = [string]$Segment.ScanText
153
+ $comparison = [System.StringComparison]::OrdinalIgnoreCase
154
+ return ($scanText.IndexOf($joined, $comparison) -ge 0 -or
155
+ $scanText.IndexOf(($optionName + ' ' + $optionValue), $comparison) -ge 0)
156
+ }
157
+
88
158
  function Test-ParallelAbandonCommandInScope {
89
159
  <#
90
160
  .SYNOPSIS
91
161
  Report whether a command carries the abandon disposition token at all.
162
+ .DESCRIPTION
163
+ Evaluated per segment through the shared parser rather than as a containment test
164
+ over the whole command string. In scope when ANY segment requests the abandon
165
+ disposition in either accepted spelling.
92
166
  .PARAMETER NormalizedCommand
93
167
  The whitespace-normalized command text.
94
168
  .OUTPUTS
@@ -105,15 +179,25 @@ function Test-ParallelAbandonCommandInScope {
105
179
  if ([string]::IsNullOrWhiteSpace($NormalizedCommand)) {
106
180
  return $false
107
181
  }
108
- return $NormalizedCommand.Contains(
109
- $script:AbandonDispositionToken,
110
- [System.StringComparison]::OrdinalIgnoreCase)
182
+
183
+ foreach ($segment in @(Read-CommandLineSegment -CommandText $NormalizedCommand)) {
184
+ if (Test-ParallelAbandonSegmentDisposition -Segment $segment) {
185
+ return $true
186
+ }
187
+ }
188
+
189
+ return $false
111
190
  }
112
191
 
113
192
  function Test-ParallelAbandonCommandConfirmed {
114
193
  <#
115
194
  .SYNOPSIS
116
195
  Report whether a command carries the confirmation marker.
196
+ .DESCRIPTION
197
+ Confirmed only when a SINGLE segment carries both the abandon disposition and the
198
+ confirmation marker. The confirmation is the caller's deliberate acknowledgement of
199
+ this abandon, so a marker echoed in an unrelated segment is not an acknowledgement
200
+ of the command that follows it.
117
201
  .PARAMETER NormalizedCommand
118
202
  The whitespace-normalized command text.
119
203
  .OUTPUTS
@@ -130,9 +214,22 @@ function Test-ParallelAbandonCommandConfirmed {
130
214
  if ([string]::IsNullOrWhiteSpace($NormalizedCommand)) {
131
215
  return $false
132
216
  }
133
- return $NormalizedCommand.Contains(
134
- $script:AbandonConfirmToken,
135
- [System.StringComparison]::OrdinalIgnoreCase)
217
+
218
+ foreach ($segment in @(Read-CommandLineSegment -CommandText $NormalizedCommand)) {
219
+ $tokens = @($segment.Tokens)
220
+ if (-not (Test-ParallelAbandonSegmentDisposition -Segment $segment)) {
221
+ continue
222
+ }
223
+ if ($tokens -contains $script:AbandonConfirmToken) {
224
+ return $true
225
+ }
226
+ if ((Test-CommandLineSegmentRawScan -Segment $segment) -and
227
+ ([string]$segment.ScanText).IndexOf($script:AbandonConfirmToken, [System.StringComparison]::OrdinalIgnoreCase) -ge 0) {
228
+ return $true
229
+ }
230
+ }
231
+
232
+ return $false
136
233
  }
137
234
 
138
235
  function Get-ParallelAbandonGateAllowDecision {
@@ -30,8 +30,21 @@ param()
30
30
 
31
31
 
32
32
  Import-Module (Join-Path $PSScriptRoot '../lib/hook-payload/HookPayload.psm1') -Force
33
+ # Sanctioned-removal manifest reader (issue #635), consumed by the manifest branch in
34
+ # Invoke-ParallelWorktreeRemovalGateDecision. Same module the epic gate imports.
35
+ Import-Module (Join-Path $PSScriptRoot '../lib/cleanup-manifest/CleanupWorktreeManifest.psm1') -Force
36
+ # Shared command-line parser (issue #545), consumed by the scope filter in
37
+ # Invoke-ParallelWorktreeRemovalGateDecision and by Get-ParallelWorktreeRemovalCommandPath.
38
+ # Both call sites are byte-for-byte the calls the epic gate makes, so the duplicated
39
+ # concern now has one implementation.
40
+ . (Join-Path $PSScriptRoot 'hook-command-scanner.ps1')
41
+ . (Join-Path $PSScriptRoot 'hook-command-invocation.ps1')
33
42
  $script:ParallelCheckpointPath = 'artifacts/orchestration/parallel-orchestrator-state.json'
34
43
  $script:AllowedMergeStatuses = @('merged', 'worktree_removed')
44
+ # Sanctioned-removal manifest location. Recorded here beside the checkpoint path so
45
+ # every hook-read document this gate consults is named in one place; the module owns
46
+ # the read itself.
47
+ $script:CleanupWorktreeManifestPath = 'artifacts/orchestration/cleanup-worktrees-manifest.json'
35
48
 
36
49
  function Get-ParallelWorktreeRemovalGateCheckpointContent {
37
50
  <#
@@ -55,6 +68,24 @@ function Get-ParallelWorktreeRemovalCommandPath {
55
68
  <#
56
69
  .SYNOPSIS
57
70
  Extract the target worktree path argument from a git worktree remove command.
71
+ .DESCRIPTION
72
+ The operand comes from the segment that structurally invokes git worktree remove,
73
+ so a 'cd <path> &&' segment chained before the removal contributes nothing and a
74
+ quoted mention of the phrase resolves to no operand at all. Quotes around the path
75
+ are already stripped by the tokenizer.
76
+
77
+ '--force' is a zero-argument flag, so it never contributes an operand and may be
78
+ written on either side of the target path. Its presence is read structurally through
79
+ Test-CommandLineFlag rather than by searching the raw text, so a '--force' spelling
80
+ that appears inside an unrelated quoted argument cannot change how the operand list
81
+ is read. When no operand resolves, a present '--force' is reported in its place, so
82
+ the checkpoint lookup fails closed on a value that matches no recorded worktree_path
83
+ - the same value the previous raw-text pattern returned for that input.
84
+
85
+ This body is identical to Get-EpicWorktreeRemovalCommandPath in
86
+ enforce-epic-worktree-removal-gate.ps1. The two gates fire on the same command and
87
+ now delegate the shared concern to one parser rather than to two patterns that had
88
+ already diverged from the Codex copy.
58
89
  .PARAMETER CommandText
59
90
  The Bash command text under evaluation.
60
91
  .OUTPUTS
@@ -67,8 +98,14 @@ function Get-ParallelWorktreeRemovalCommandPath {
67
98
  [string] $CommandText
68
99
  )
69
100
 
70
- if ($CommandText -match '(?i)\bgit\s+worktree\s+remove\s+(?<path>\S+)') {
71
- return $Matches['path'].Trim('"''')
101
+ $hasForce = Test-CommandLineFlag -CommandText $CommandText -CommandWord 'git' -SubcommandPath @('worktree', 'remove') -FlagName '--force'
102
+ $operands = @(Get-CommandLineOperand -CommandText $CommandText -CommandWord 'git' -SubcommandPath @('worktree', 'remove'))
103
+
104
+ if ($operands.Count -gt 0) {
105
+ return $operands[0]
106
+ }
107
+ if ($hasForce) {
108
+ return '--force'
72
109
  }
73
110
  return $null
74
111
  }
@@ -203,7 +240,10 @@ function Invoke-ParallelWorktreeRemovalGateDecision {
203
240
  return Get-ParallelWorktreeGateAllowDecision
204
241
  }
205
242
 
206
- if ($commandText -notmatch '(?i)\bgit\s+worktree\s+remove\b') {
243
+ # Scope filter. The test is structural, so a relocating spelling such as
244
+ # 'git -C <dir> worktree remove <path>' is now in scope and quoted prose that merely
245
+ # mentions the phrase is not (issue #545). This is the same call the epic gate makes.
246
+ if (-not (Test-CommandLineInvocation -CommandText $commandText -CommandWord 'git' -SubcommandPath @('worktree', 'remove'))) {
207
247
  return Get-ParallelWorktreeGateAllowDecision
208
248
  }
209
249
 
@@ -224,6 +264,21 @@ function Invoke-ParallelWorktreeRemovalGateDecision {
224
264
  return Get-ParallelWorktreeGateAllowDecision
225
265
  }
226
266
 
267
+ # Sanctioned-removal manifest branch (issue #635). It runs last, so a
268
+ # checkpoint-authorized removal still allows at the same decision point it does
269
+ # today and no transcript attribution changes.
270
+ #
271
+ # The coverage test is a PRESENCE test, deliberately not an authorization test. A
272
+ # target this checkpoint records at all is excluded from this branch regardless of
273
+ # that record's merge_status, so a removal the checkpoint does not authorize still
274
+ # reaches the unchanged deny below. This gate defines only the parallel checkpoint
275
+ # seam and has no epic-checkpoint seam, so its exclusion covers the items array
276
+ # only; the epic gate covers the features array with its own exclusion.
277
+ if (-not (Test-CleanupManifestCheckpointCoversPath -Checkpoint $checkpoint -RecordArrayName 'items' -WorktreePath $worktreePath) -and
278
+ (Test-CleanupWorktreeManifestAuthorizesRemoval -WorktreePath $worktreePath)) {
279
+ return Get-ParallelWorktreeGateAllowDecision
280
+ }
281
+
227
282
  return Get-ParallelWorktreeGateBlockDecision -Reason "PARALLEL_WORKTREE_REMOVAL_BLOCKED: git worktree remove for '$worktreePath' requires a matching parallel checkpoint items[] record with merge_status in {merged, worktree_removed}. The checkpoint was unreadable, no matching record was found, or merge_status was not yet safe for removal."
228
283
  }
229
284
 
@@ -26,6 +26,11 @@
26
26
  [CmdletBinding()]
27
27
  param()
28
28
 
29
+ # Shared command-line parser (issue #545). Dot-sourced here as well as from the parent hook
30
+ # so this file keeps working when a test dot-sources it directly.
31
+ . (Join-Path $PSScriptRoot 'hook-command-scanner.ps1')
32
+ . (Join-Path $PSScriptRoot 'hook-command-invocation.ps1')
33
+
29
34
  function Test-PrAuthorReceiptVerification {
30
35
  <#
31
36
  .SYNOPSIS
@@ -166,16 +171,43 @@ function Get-PrAuthorBypassReason {
166
171
  [bool] $ContextExists
167
172
  )
168
173
 
169
- # Only act on gh pr create or gh pr edit subcommands.
170
- $isPrCreate = $CommandText -match '(?i)\bgh\s+pr\s+create\b'
171
- $isPrEdit = $CommandText -match '(?i)\bgh\s+pr\s+edit\b'
174
+ # Only act on gh pr create or gh pr edit subcommands. The test is structural: a segment
175
+ # must INVOKE gh with the subcommand path, so quoted prose that merely mentions the
176
+ # phrase no longer triggers the gate, and a relocating spelling that carries a gh global
177
+ # option between the command word and the subcommand now does.
178
+ $isPrCreate = Test-CommandLineInvocation -CommandText $CommandText -CommandWord 'gh' -SubcommandPath @('pr', 'create')
179
+ $isPrEdit = Test-CommandLineInvocation -CommandText $CommandText -CommandWord 'gh' -SubcommandPath @('pr', 'edit')
172
180
 
173
181
  if (-not $isPrCreate -and -not $isPrEdit) {
174
182
  return $null
175
183
  }
176
184
 
177
- $hasBodyFile = $CommandText -match '(?i)--body-file\b'
178
- $hasInlineBody = $CommandText -match '(?i)--body(?!-file)\b'
185
+ # Flag presence comes from the matched segment's tokens. Exact token comparison is what
186
+ # keeps '--body' from matching '--body-file'; the previous negative lookahead expressed
187
+ # the same distinction over raw text.
188
+ $subcommandPath = if ($isPrCreate) { @('pr', 'create') } else { @('pr', 'edit') }
189
+ $hasBodyFile = Test-CommandLineFlag -CommandText $CommandText -CommandWord 'gh' -SubcommandPath $subcommandPath -FlagName '--body-file'
190
+ $hasInlineBody = Test-CommandLineFlag -CommandText $CommandText -CommandWord 'gh' -SubcommandPath $subcommandPath -FlagName '--body'
191
+
192
+ # Raw-scan fallback for a wrapper-led, live-substitution, or unbalanced segment. A
193
+ # wrapper's quoted argument collapses into ONE token, so both flags read absent there and
194
+ # the gh pr edit no-body branch below allowed bash -c "gh pr edit 42 --body 'x'". The
195
+ # --body-file test runs first and wins, so a --body-file carried inside a wrapper is never
196
+ # misread as an inline body. That ordering restores the pre-parser routing, in which one
197
+ # whole-text --body-file match set $hasBodyFile for exactly this input.
198
+ if (-not $hasBodyFile -and -not $hasInlineBody) {
199
+ $comparison = [System.StringComparison]::OrdinalIgnoreCase
200
+ foreach ($segment in @(Read-CommandLineSegment -CommandText $CommandText)) {
201
+ if (-not (Test-CommandLineSegmentRawScan -Segment $segment)) {
202
+ continue
203
+ }
204
+ if ($segment.ScanText.IndexOf('--body-file', $comparison) -ge 0) {
205
+ $hasBodyFile = $true
206
+ } elseif ($segment.ScanText.IndexOf('--body', $comparison) -ge 0) {
207
+ $hasInlineBody = $true
208
+ }
209
+ }
210
+ }
179
211
 
180
212
  # Case A: gh pr create OR gh pr edit with inline --body (not --body-file). Evaluated before the
181
213
  # gh pr edit no-body allow short-circuit so inline-body edits are blocked, not allowed.
@@ -9,6 +9,11 @@
9
9
  structural extraction only: no logic, decision behavior, or reason-string wording changed.
10
10
  #>
11
11
 
12
+ # Shared command-line parser (issue #545). Dot-sourced here as well as from the parent hook
13
+ # so this file keeps working when a test dot-sources it directly.
14
+ . (Join-Path $PSScriptRoot 'hook-command-scanner.ps1')
15
+ . (Join-Path $PSScriptRoot 'hook-command-invocation.ps1')
16
+
12
17
  function Get-PrAuthorCheckpointContent {
13
18
  <#
14
19
  .SYNOPSIS
@@ -64,7 +69,9 @@ function Test-EpicBaseBranchOverride {
64
69
 
65
70
  # The epic-mode base-branch override only constrains gh pr create; gh pr edit does not
66
71
  # re-target the base branch, so non-create commands are out of scope for this check.
67
- if ($CommandText -notmatch '(?i)\bgh\s+pr\s+create\b') {
72
+ # The test is structural, so a quoted mention of the phrase is out of scope and a
73
+ # relocating spelling carrying a gh global option is in scope (issue #545).
74
+ if (-not (Test-CommandLineInvocation -CommandText $CommandText -CommandWord 'gh' -SubcommandPath @('pr', 'create'))) {
68
75
  return $null
69
76
  }
70
77
 
@@ -96,7 +103,11 @@ function Test-EpicBaseBranchOverride {
96
103
  return "EPIC_BASE_BRANCH_MISMATCH: checkpoint has epic_mode == true but no ``epic_context.integration_branch`` is recorded; ``gh pr create`` cannot be verified against the required ``--base`` value."
97
104
  }
98
105
 
99
- if ($CommandText -cnotmatch [regex]::Escape("--base $integrationBranch")) {
106
+ # The --base value is taken from the matched segment's tokens, so a chained segment that
107
+ # happens to carry a --base token contributes nothing here. The comparison stays
108
+ # case-sensitive, as the previous -cnotmatch form was, because a branch name is.
109
+ $baseValue = Get-CommandLineFlagValue -CommandText $CommandText -CommandWord 'gh' -SubcommandPath @('pr', 'create') -FlagName '--base'
110
+ if ($null -eq $baseValue -or $baseValue -cne $integrationBranch) {
100
111
  return "EPIC_BASE_BRANCH_MISMATCH: ``gh pr create`` must pass ``--base $integrationBranch`` (``epic_context.integration_branch``) under ``epic_mode``; the command does not carry a matching ``--base`` argument."
101
112
  }
102
113