@danmoisan/drm-copilot-mcp 1.1.10 → 1.1.12

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 (82) hide show
  1. package/out/mcp-server.js +3517 -1104
  2. package/package.json +1 -1
  3. package/resources/claude-customizations/.claude/agents/epic-orchestrator.md +1 -1
  4. package/resources/claude-customizations/.claude/agents/epic-planner.md +4 -1
  5. package/resources/claude-customizations/.claude/agents/parallel-orchestrator.md +15 -2
  6. package/resources/claude-customizations/.claude/agents/parallel-planner.md +3 -0
  7. package/resources/claude-customizations/.claude/agents/pr-author.md +15 -0
  8. package/resources/claude-customizations/.claude/hooks/enforce-epic-merge-gate-authorization.ps1 +443 -0
  9. package/resources/claude-customizations/.claude/hooks/enforce-epic-merge-gate.ps1 +82 -51
  10. package/resources/claude-customizations/.claude/hooks/enforce-epic-worktree-removal-gate.ps1 +52 -3
  11. package/resources/claude-customizations/.claude/hooks/enforce-model-routing-receipt.ps1 +120 -5
  12. package/resources/claude-customizations/.claude/hooks/enforce-orchestration-preimplementation-gate-epic-scope.ps1 +127 -0
  13. package/resources/claude-customizations/.claude/hooks/enforce-orchestration-preimplementation-gate-helpers.ps1 +163 -15
  14. package/resources/claude-customizations/.claude/hooks/enforce-orchestration-preimplementation-gate.ps1 +21 -28
  15. package/resources/claude-customizations/.claude/hooks/enforce-parallel-abandon-gate.ps1 +103 -6
  16. package/resources/claude-customizations/.claude/hooks/enforce-parallel-worktree-removal-gate.ps1 +124 -8
  17. package/resources/claude-customizations/.claude/hooks/enforce-pr-author-skill-helpers.ps1 +171 -15
  18. package/resources/claude-customizations/.claude/hooks/enforce-pr-author-skill.epic-base-branch.ps1 +50 -10
  19. package/resources/claude-customizations/.claude/hooks/enforce-pr-author-skill.ps1 +3 -1
  20. package/resources/claude-customizations/.claude/hooks/enforce-prd-feature-before-planner-helpers.ps1 +332 -0
  21. package/resources/claude-customizations/.claude/hooks/enforce-prd-feature-before-planner.ps1 +211 -182
  22. package/resources/claude-customizations/.claude/hooks/enforce-promotion-mcp-only.ps1 +35 -7
  23. package/resources/claude-customizations/.claude/hooks/hook-command-invocation.ps1 +483 -0
  24. package/resources/claude-customizations/.claude/hooks/hook-command-scanner.ps1 +483 -0
  25. package/resources/claude-customizations/.claude/hooks/validate-bash.ps1 +218 -6
  26. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadius.psm1 +18 -75
  27. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusConflict.psm1 +290 -0
  28. package/resources/claude-customizations/.claude/lib/cleanup-manifest/CleanupWorktreeManifest.psm1 +415 -0
  29. package/resources/claude-customizations/.claude/lib/codex-routing/CodexDeployment.psm1 +1 -0
  30. package/resources/claude-customizations/.claude/lib/project-file-merge/ProjectFileMerge.psm1 +355 -0
  31. package/resources/claude-customizations/.claude/lib/project-file-merge/ProjectFileMergeGrammar.psm1 +318 -0
  32. package/resources/claude-customizations/.claude/lib/project-file-merge/Resolve-MergeableConflict.ps1 +229 -0
  33. package/resources/claude-customizations/.claude/lib/worktree-resolution/EpicScopeReadiness.psm1 +176 -0
  34. package/resources/claude-customizations/.claude/lib/worktree-resolution/EpicScopeResolution.psm1 +369 -0
  35. package/resources/claude-customizations/.claude/lib/worktree-resolution/WorktreeItemResolution.psm1 +392 -0
  36. package/resources/claude-customizations/.claude/lib/worktree-resolution/WorktreeResolution.psm1 +500 -0
  37. package/resources/claude-customizations/.claude/lib/worktree-resolution/WorktreeTargetResolution.psm1 +347 -0
  38. package/resources/claude-customizations/.claude/rules/orchestrator-state.md +27 -0
  39. package/resources/claude-customizations/.claude/rules/parallel-orchestration.md +66 -2
  40. package/resources/claude-customizations/.claude/skills/cleanup-merged-worktrees/SKILL.md +323 -16
  41. package/resources/claude-customizations/.claude/skills/epic-orchestrate/SKILL.md +14 -1
  42. package/resources/claude-customizations/.claude/skills/epic-plan/SKILL.md +4 -0
  43. package/resources/claude-customizations/.claude/skills/orchestrate/SKILL.md +46 -1
  44. package/resources/claude-customizations/.claude/skills/parallel-add/SKILL.md +3 -1
  45. package/resources/claude-customizations/.claude/skills/parallel-orchestrate/SKILL.md +75 -8
  46. package/resources/claude-customizations/.claude/skills/parallel-plan/SKILL.md +6 -0
  47. package/resources/claude-customizations/.claude/skills/powershell-orchestration-state-machine/SKILL.md +29 -1
  48. package/resources/claude-customizations/.claude/skills/pr-author/SKILL.md +15 -0
  49. package/resources/claude-customizations/.claude/skills/quota-throttling/SKILL.md +221 -0
  50. package/resources/claude-customizations/config/blast-radius.json +7 -0
  51. package/resources/claude-customizations/pack-manifests/core.json +16 -0
  52. package/resources/codex-and-agents-customizations/.agents/skills/codex-model-routing/SKILL.md +13 -14
  53. package/resources/codex-and-agents-customizations/.agents/skills/orchestrate/SKILL.md +41 -0
  54. package/resources/codex-and-agents-customizations/.agents/skills/orchestrator-state/SKILL.md +41 -0
  55. package/resources/codex-and-agents-customizations/.agents/skills/repo-automation-adapter/SKILL.md +26 -0
  56. package/resources/codex-and-agents-customizations/.codex/config.toml +1 -1
  57. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-epic-merge-gate.ps1 +242 -3
  58. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-epic-planning-only.ps1 +67 -12
  59. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-epic-worktree-removal-gate.ps1 +34 -8
  60. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-orchestration-preimplementation-gate-helpers.ps1 +163 -15
  61. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-orchestration-preimplementation-gate.ps1 +6 -1
  62. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-promotion-mcp-only.ps1 +34 -7
  63. package/resources/codex-and-agents-customizations/.codex/hooks/hook-command-invocation.ps1 +483 -0
  64. package/resources/codex-and-agents-customizations/.codex/hooks/hook-command-scanner.ps1 +483 -0
  65. package/resources/codex-and-agents-customizations/.codex/hooks/validate-bash.ps1 +130 -2
  66. package/resources/codex-and-agents-customizations/.codex/scripts/Resolve-CodexDeployment.ps1 +87 -0
  67. package/resources/codex-and-agents-customizations/.codex/scripts/Resolve-CodexTopology.ps1 +96 -0
  68. package/resources/codex-and-agents-customizations/.codex/scripts/codex-routing-cli-common.ps1 +370 -0
  69. package/resources/codex-and-agents-customizations/.codex/scripts/epic-child-launch-contract.ps1 +475 -0
  70. package/resources/codex-and-agents-customizations/.codex/scripts/epic-child-launch-runtime.ps1 +445 -0
  71. package/resources/codex-and-agents-customizations/.codex/scripts/epic-child-persistence-runtime.ps1 +118 -0
  72. package/resources/codex-and-agents-customizations/.codex/scripts/epic-child-sandbox-preflight.ps1 +69 -0
  73. package/resources/codex-and-agents-customizations/.codex/scripts/launch-epic-child-wave.ps1 +471 -0
  74. package/resources/codex-and-agents-customizations/.codex/scripts/post-codex-worktree-session.ps1 +280 -0
  75. package/resources/codex-and-agents-customizations/.codex/scripts/resume-epic-child.ps1 +263 -0
  76. package/resources/codex-and-agents-customizations/.codex-variants/csharp-legacy/agents/csharp-typed-engineer.toml +2 -1
  77. package/resources/codex-and-agents-customizations/pack-manifests/core.json +23 -1
  78. package/resources/config/orchestration-handoff-registry.json +138 -0
  79. package/resources/config/orchestration-handoff.schema.json +472 -0
  80. package/resources/lib/codex-routing/CodexDeployment.psm1 +315 -0
  81. package/resources/lib/codex-routing/CodexTopology.psm1 +394 -0
  82. package/resources/powershell/PoshQC/settings/pester.runsettings.psd1 +64 -0
@@ -27,15 +27,34 @@ $script:OrchestrationBookkeepingTrees = @(
27
27
  'artifacts/orchestration/'
28
28
  )
29
29
 
30
- # Characters that make a command line statically unresolvable: shell interpolation and
31
- # redirection (D4 row 12). Tested across the whole line rather than per operand, because a
32
- # redirection anywhere in the line moves content the operand list cannot describe.
33
- $script:UnresolvableCommandCharacters = [char[]]@('$', '`', '>', '<')
30
+ # Characters that make a command line statically unresolvable (D4 row 12, as narrowed by
31
+ # issue #663). Interpolation characters are unresolvable in every quote state, because a
32
+ # double-quoted span still expands them. Redirection characters are unresolvable only
33
+ # outside a quoted span: inside single or double quotes the shell treats `<` and `>` as
34
+ # literal text, so a quoted Co-Authored-By trailer is not a redirection.
35
+ $script:InterpolationCommandCharacters = [char[]]@('$', '`')
36
+ $script:RedirectionCommandCharacters = [char[]]@('>', '<')
34
37
 
35
38
  # Wildcards that make an operand a glob (D4 row 15). Only the literal prefix before the
36
39
  # first of these is prefix-tested.
37
40
  $script:PathspecWildcardCharacters = [char[]]@('*', '?', '[')
38
41
 
42
+ # The single repository selector modelled between the command name and the subcommand: D4
43
+ # row 14 as narrowed by issue #671, the Lexical Absolute-Canonical Selector (LACS) rule in
44
+ # docs/features/active/2026-09-13-preimplementation-gate-worktree-selector-671/spec.md.
45
+ # Compared case-sensitively; no other relocating spelling is modelled.
46
+ $script:OrchestrationSelectorOptionName = '-C'
47
+
48
+ # Accepted widening (issue #671): a selector naming a nested subdirectory of a worktree
49
+ # passes L1 through L8 lexically yet relocates what a relative operand denotes, so
50
+ # `docs/features/active/X` under `-C <root>/tests/fixtures/resolve_execute_plan_prompt`
51
+ # stages `tests/fixtures/resolve_execute_plan_prompt/docs/features/active/X`. Measured
52
+ # exposure in this repository is seven Markdown test fixtures under the
53
+ # resolve_execute_plan_prompt fixture tree, all of which the gate's file_path leg already
54
+ # classifies as non-implementation. This module stays pure string logic; the epic's F1
55
+ # resolution module composes upstream to close the escape later without a schema change,
56
+ # the same posture D4 row 16 takes toward issue #516.
57
+
39
58
  function Split-OrchestrationCommandLine {
40
59
  <#
41
60
  .SYNOPSIS
@@ -88,6 +107,59 @@ function Split-OrchestrationCommandLine {
88
107
  }
89
108
  }
90
109
 
110
+ function Test-OrchestrationCommandTextUnresolvable {
111
+ <#
112
+ .SYNOPSIS
113
+ Reports whether a command line carries a statically unresolvable character.
114
+ .DESCRIPTION
115
+ Realizes D4 row 12 as narrowed by issue #663. Quote state is tracked with the same
116
+ state machine as Split-OrchestrationCommandLine. An interpolation character (`$` or
117
+ backtick) answers true in any quote state; a redirection character (`<` or `>`)
118
+ answers true only outside a quoted span. Backslash escapes are not modelled, so a
119
+ backslash before any quote character, or anywhere inside a double-quoted span,
120
+ answers true: the shell may end the span where this scan does not (fail closed).
121
+ .PARAMETER CommandText
122
+ The full command line as the shell would receive it.
123
+ .OUTPUTS
124
+ System.Boolean
125
+ #>
126
+ [CmdletBinding()]
127
+ [OutputType([bool])]
128
+ param([Parameter(Mandatory)][AllowEmptyString()][string] $CommandText)
129
+
130
+ # An escaped quote moves a span boundary the scan cannot model (issue #663 remediation CR-1).
131
+ if ($CommandText.Contains('\"') -or $CommandText.Contains("\'")) {
132
+ return $true
133
+ }
134
+
135
+ $openQuote = [char]0
136
+
137
+ # Scan every character once, tracking whether it sits inside a quoted span so that
138
+ # redirection characters are judged only where the shell would honour them.
139
+ foreach ($character in $CommandText.ToCharArray()) {
140
+ if ($script:InterpolationCommandCharacters -contains $character) {
141
+ return $true
142
+ }
143
+
144
+ # Decide by quote state: inside a span only the closing quote matters, and any
145
+ # backslash inside a double-quoted span is an unmodelled escape; outside a span a
146
+ # quote opens one and a redirection character is unresolvable.
147
+ if ($openQuote -ne [char]0) {
148
+ if ($openQuote -eq '"' -and $character -eq '\') {
149
+ return $true
150
+ }
151
+ if ($character -eq $openQuote) {
152
+ $openQuote = [char]0
153
+ }
154
+ } elseif ($character -eq '"' -or $character -eq "'") {
155
+ $openQuote = $character
156
+ } elseif ($script:RedirectionCommandCharacters -contains $character) {
157
+ return $true
158
+ }
159
+ }
160
+ return $false
161
+ }
162
+
91
163
  function ConvertTo-OrchestrationCommandToken {
92
164
  <#
93
165
  .SYNOPSIS
@@ -203,6 +275,67 @@ function Test-ExemptOrchestrationOperand {
203
275
  return $false
204
276
  }
205
277
 
278
+ function Test-ExemptOrchestrationSelector {
279
+ <#
280
+ .SYNOPSIS
281
+ Tests the repository selector of one tokenized segment against LACS L1 through L8.
282
+ .DESCRIPTION
283
+ Realizes D4 row 14 as narrowed by issue #671 (the Lexical Absolute-Canonical Selector
284
+ rule in docs/features/active/2026-09-13-preimplementation-gate-worktree-selector-671/spec.md).
285
+ Returns true only when the segment reads `git -C <value> <subcommand> ...` and the
286
+ value is decidable from the command text alone: exactly one case-sensitive `-C`, at
287
+ index 1 (L1, L2); a value at index 2 and the subcommand at index 3 (L3); a
288
+ drive-lettered or rooted, non-UNC value after separator normalization (L4); no `.` or
289
+ `..` segment (L5); no wildcard (L6); no colon other than the drive colon (L7); and a
290
+ non-empty value (L8). Each rejection writes a diagnostic token through Write-Debug.
291
+ The tokens are not contractual and change no decision.
292
+ L3 checks only that a non-option token follows the value; the caller
293
+ Test-ExemptOrchestrationSegmentToken rejects any subcommand other than add or commit.
294
+ .OUTPUTS
295
+ System.Boolean
296
+ #>
297
+ [CmdletBinding()]
298
+ [OutputType([bool])]
299
+ param([Parameter(Mandatory)][AllowEmptyCollection()][AllowEmptyString()][string[]] $Token)
300
+
301
+ $selector = $script:OrchestrationSelectorOptionName
302
+ if ($Token.Count -lt 2 -or $Token[1] -cne $selector) {
303
+ Write-Debug 'PREIMPL_SELECTOR_UNMODELLED_OPTION: the token after the command name is not the -C selector.'
304
+ return $false
305
+ }
306
+ if (@($Token | Where-Object { $_ -ceq $selector }).Count -gt 1) {
307
+ Write-Debug 'PREIMPL_SELECTOR_REPEATED: the segment carries more than one -C selector.'
308
+ return $false
309
+ }
310
+ if ($Token.Count -lt 4 -or $Token[3].StartsWith('-')) {
311
+ Write-Debug 'PREIMPL_SELECTOR_MALFORMED: no subcommand immediately follows the selector value.'
312
+ return $false
313
+ }
314
+ $value = $Token[2]
315
+ if (-not $value) {
316
+ Write-Debug 'PREIMPL_SELECTOR_MALFORMED: the selector value is empty.'
317
+ return $false
318
+ }
319
+
320
+ $normalized = $value -replace '\\', '/'
321
+ $isDriveRooted = $normalized -match '^[A-Za-z]:/'
322
+ if ($normalized.StartsWith('//') -or -not ($isDriveRooted -or $normalized.StartsWith('/'))) {
323
+ Write-Debug 'PREIMPL_SELECTOR_NOT_ROOTED: the selector value is relative or UNC.'
324
+ return $false
325
+ }
326
+ $segments = $normalized -split '/'
327
+ if ($segments -contains '..' -or $segments -contains '.') {
328
+ Write-Debug 'PREIMPL_SELECTOR_TRAVERSAL: the selector value carries a dot segment.'
329
+ return $false
330
+ }
331
+ $afterDrive = if ($isDriveRooted) { $normalized.Substring(2) } else { $normalized }
332
+ if ($normalized.IndexOfAny($script:PathspecWildcardCharacters) -ge 0 -or $afterDrive.Contains(':')) {
333
+ Write-Debug 'PREIMPL_SELECTOR_NOT_LITERAL: the selector value carries a wildcard or a stray colon.'
334
+ return $false
335
+ }
336
+ return $true
337
+ }
338
+
206
339
  function Test-ExemptOrchestrationSegmentToken {
207
340
  <#
208
341
  .SYNOPSIS
@@ -218,18 +351,27 @@ function Test-ExemptOrchestrationSegmentToken {
218
351
  #>
219
352
  [CmdletBinding()]
220
353
  [OutputType([bool])]
221
- param([Parameter(Mandatory)][AllowEmptyCollection()][string[]] $Token)
354
+ param([Parameter(Mandatory)][AllowEmptyCollection()][AllowEmptyString()][string[]] $Token)
222
355
 
223
356
  if ($Token.Count -lt 2) {
224
357
  return $false
225
358
  }
226
359
 
227
- # Row 14: the command name leads and the subcommand follows immediately. Anything in
228
- # between - a relocating option or an env-style prefix - moves the pathspec base and is
229
- # rejected here rather than modelled.
360
+ # Row 14, narrowed by issue #671: the command name leads and the subcommand follows it,
361
+ # either immediately or after one lexically absolute `-C <value>` selector (LACS L1-L8).
362
+ # Anything else in between - an unmodelled or unresolvable relocating option, or an
363
+ # env-style prefix - moves the pathspec base undecidably and is rejected here.
230
364
  if ($Token[0] -cne 'git') {
231
365
  return $false
232
366
  }
367
+ if ($Token[1] -cne 'add' -and $Token[1] -cne 'commit') {
368
+ if (-not (Test-ExemptOrchestrationSelector -Token $Token)) {
369
+ return $false
370
+ }
371
+ # Drop the accepted selector and its value so the subcommand is again at index 1
372
+ # and operand collection below starts at index 2 exactly as before.
373
+ $Token = @($Token[0]) + @($Token[3..($Token.Count - 1)])
374
+ }
233
375
  $subcommand = $Token[1]
234
376
  if ($subcommand -cne 'add' -and $subcommand -cne 'commit') {
235
377
  return $false
@@ -322,9 +464,9 @@ function Test-ExemptOrchestrationStagingCommand {
322
464
  return $false
323
465
  }
324
466
 
325
- # Row 12: interpolation and redirection are not statically resolvable, so the operand
326
- # list cannot be trusted to describe what the line actually touches.
327
- if ($CommandText.IndexOfAny($script:UnresolvableCommandCharacters) -ge 0) {
467
+ # Row 12: interpolation anywhere, redirection outside quotes, and unmodelled backslash
468
+ # escapes are not statically resolvable, so the operand list cannot be trusted.
469
+ if (Test-OrchestrationCommandTextUnresolvable -CommandText $CommandText) {
328
470
  return $false
329
471
  }
330
472
 
@@ -339,11 +481,17 @@ function Test-ExemptOrchestrationStagingCommand {
339
481
  return $false
340
482
  }
341
483
 
342
- foreach ($segment in $segments) {
343
- $tokens = @(ConvertTo-OrchestrationCommandToken -Segment $segment)
344
- if (-not (Test-ExemptOrchestrationSegmentToken -Token $tokens)) {
345
- return $false
484
+ # Fail closed (issue #671): an error raised while classifying any segment is a parse
485
+ # ambiguity, so it answers false instead of letting the caller continue past it.
486
+ try {
487
+ foreach ($segment in $segments) {
488
+ $tokens = @(ConvertTo-OrchestrationCommandToken -Segment $segment)
489
+ if (-not (Test-ExemptOrchestrationSegmentToken -Token $tokens)) {
490
+ return $false
491
+ }
346
492
  }
493
+ } catch {
494
+ return $false
347
495
  }
348
496
  return $true
349
497
  }
@@ -19,6 +19,14 @@ 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
+ # Epic-scope command and path legs (issue #663, decision D2), plus the two per-mode read
23
+ # seams relocated from this file to keep it inside the 500-line cap.
24
+ . (Join-Path $PSScriptRoot 'enforce-orchestration-preimplementation-gate-epic-scope.ps1')
25
+
26
+ # Shared command-line parser (issue #545): per-segment scan text and structural matching.
27
+ . (Join-Path $PSScriptRoot 'hook-command-scanner.ps1')
28
+ . (Join-Path $PSScriptRoot 'hook-command-invocation.ps1')
29
+
22
30
  # The readiness checkpoint this gate reads and names in its block message.
23
31
  $script:CheckpointPath = 'artifacts/orchestration/orchestrator-state.json'
24
32
 
@@ -132,9 +140,11 @@ function Test-ImplementationCommand {
132
140
  '(^|\s)npx\s+(prettier|eslint|tsc|jest)\b',
133
141
  '(^|\s)pwsh\s+.*(Invoke-Pester|tests/scripts/)'
134
142
  )
143
+ $scanText = @(Read-CommandLineSegment -CommandText $normalizedCommand).ScanText
144
+ $isStaging = @('add', 'commit').Where({ Test-CommandLineInvocation -CommandText $normalizedCommand -CommandWord 'git' -SubcommandPath @($_) }).Count -gt 0
135
145
 
136
146
  for ($index = 0; $index -lt $implementationCommandPatterns.Count; $index++) {
137
- if ($normalizedCommand -notmatch $implementationCommandPatterns[$index]) {
147
+ if (-not (($scanText | Where-Object { $_ -match $implementationCommandPatterns[$index] }) -or ($index -eq 0 -and $isStaging))) {
138
148
  continue
139
149
  }
140
150
  # Allow-side only (issue #539). Index 0 is the git staging trigger, whose pattern
@@ -255,33 +265,6 @@ function Get-CheckpointContent {
255
265
  return Get-Content -Raw -LiteralPath $script:CheckpointPath
256
266
  }
257
267
 
258
- # The two per-mode read seams (issue #554). Each takes its path from the fixed mode
259
- # table and never from a delegation's own text; an absent file returns an empty
260
- # string, which the readiness predicate then treats as a deny.
261
- function Get-EpicCheckpointContent {
262
- [CmdletBinding()]
263
- [OutputType([string])]
264
- param()
265
-
266
- $path = Get-OrchestrationDelegationCheckpointPath -Mode 'epic'
267
- if (-not (Test-Path -LiteralPath $path)) {
268
- return ''
269
- }
270
- return Get-Content -Raw -LiteralPath $path
271
- }
272
-
273
- function Get-ParallelCheckpointContent {
274
- [CmdletBinding()]
275
- [OutputType([string])]
276
- param()
277
-
278
- $path = Get-OrchestrationDelegationCheckpointPath -Mode 'parallel'
279
- if (-not (Test-Path -LiteralPath $path)) {
280
- return ''
281
- }
282
- return Get-Content -Raw -LiteralPath $path
283
- }
284
-
285
268
  function Get-OrchestrationPreimplementationGateAllowDecision {
286
269
  [CmdletBinding()]
287
270
  [OutputType([System.Collections.Specialized.OrderedDictionary])]
@@ -371,6 +354,7 @@ function Invoke-OrchestrationPreimplementationGateDecision {
371
354
  # default wording they have today.
372
355
  $mode = $script:OrchestrationDelegationDefaultMode
373
356
  $prompt = ''
357
+ $command = ''
374
358
  $filePath = Get-StringProperty -Value $toolInput -Name 'file_path'
375
359
  if ($filePath) {
376
360
  $normalized = ([string]$filePath) -replace '\\', '/'
@@ -400,6 +384,15 @@ function Invoke-OrchestrationPreimplementationGateDecision {
400
384
  Get-OrchestrationModeDenyReason -Mode $mode -Failure 'declared-checkpoint-path')
401
385
  }
402
386
 
387
+ # The command and path legs consult the epic checkpoint first (issue #663). A call that
388
+ # is not epic scope yields $null and continues to the unchanged single-feature path.
389
+ if ($filePath -or $command) {
390
+ $epicDecision = Get-OrchestrationEpicScopeDecision -Command $command -FilePath $filePath
391
+ if ($null -ne $epicDecision) {
392
+ return $epicDecision
393
+ }
394
+ }
395
+
403
396
  if ($mode -eq 'epic' -or $mode -eq 'parallel') {
404
397
  $isEpic = ($mode -eq 'epic')
405
398
  $injected = if ($isEpic) { 'EpicCheckpointRaw' } else { 'ParallelCheckpointRaw' }
@@ -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,27 @@ 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
+ # Epic checkpoint location (issue #688). An epic run records per-child state in
45
+ # features[] and never writes the parallel checkpoint, so without this read every
46
+ # merged epic child worktree reached the deny below even though
47
+ # enforce-epic-worktree-removal-gate.ps1 authorized the same call. Both gates run on
48
+ # the same Bash call, so that deny won and epic worktrees could never be removed.
49
+ $script:EpicCheckpointPath = 'artifacts/orchestration/epic-orchestrator-state.json'
50
+ # Sanctioned-removal manifest location. Recorded here beside the checkpoint path so
51
+ # every hook-read document this gate consults is named in one place; the module owns
52
+ # the read itself.
53
+ $script:CleanupWorktreeManifestPath = 'artifacts/orchestration/cleanup-worktrees-manifest.json'
35
54
 
36
55
  function Get-ParallelWorktreeRemovalGateCheckpointContent {
37
56
  <#
@@ -51,10 +70,50 @@ function Get-ParallelWorktreeRemovalGateCheckpointContent {
51
70
  return (Get-Content -LiteralPath $script:ParallelCheckpointPath -Raw)
52
71
  }
53
72
 
73
+ function Get-ParallelWorktreeRemovalGateEpicCheckpointContent {
74
+ <#
75
+ .SYNOPSIS
76
+ Read the raw JSON text of the epic checkpoint. Tests mock this function
77
+ (read seam, issue #688).
78
+ .DESCRIPTION
79
+ A separate seam from the parallel checkpoint reader so a test can drive the
80
+ two documents independently: the epic branch must be exercised with the
81
+ parallel checkpoint absent, present-but-not-covering, and covering.
82
+ .OUTPUTS
83
+ System.String or $null
84
+ #>
85
+ [CmdletBinding()]
86
+ [OutputType([string])]
87
+ param()
88
+
89
+ if (-not (Test-Path -LiteralPath $script:EpicCheckpointPath -PathType Leaf)) {
90
+ return $null
91
+ }
92
+ return (Get-Content -LiteralPath $script:EpicCheckpointPath -Raw)
93
+ }
94
+
54
95
  function Get-ParallelWorktreeRemovalCommandPath {
55
96
  <#
56
97
  .SYNOPSIS
57
98
  Extract the target worktree path argument from a git worktree remove command.
99
+ .DESCRIPTION
100
+ The operand comes from the segment that structurally invokes git worktree remove,
101
+ so a 'cd <path> &&' segment chained before the removal contributes nothing and a
102
+ quoted mention of the phrase resolves to no operand at all. Quotes around the path
103
+ are already stripped by the tokenizer.
104
+
105
+ '--force' is a zero-argument flag, so it never contributes an operand and may be
106
+ written on either side of the target path. Its presence is read structurally through
107
+ Test-CommandLineFlag rather than by searching the raw text, so a '--force' spelling
108
+ that appears inside an unrelated quoted argument cannot change how the operand list
109
+ is read. When no operand resolves, a present '--force' is reported in its place, so
110
+ the checkpoint lookup fails closed on a value that matches no recorded worktree_path
111
+ - the same value the previous raw-text pattern returned for that input.
112
+
113
+ This body is identical to Get-EpicWorktreeRemovalCommandPath in
114
+ enforce-epic-worktree-removal-gate.ps1. The two gates fire on the same command and
115
+ now delegate the shared concern to one parser rather than to two patterns that had
116
+ already diverged from the Codex copy.
58
117
  .PARAMETER CommandText
59
118
  The Bash command text under evaluation.
60
119
  .OUTPUTS
@@ -67,8 +126,14 @@ function Get-ParallelWorktreeRemovalCommandPath {
67
126
  [string] $CommandText
68
127
  )
69
128
 
70
- if ($CommandText -match '(?i)\bgit\s+worktree\s+remove\s+(?<path>\S+)') {
71
- return $Matches['path'].Trim('"''')
129
+ $hasForce = Test-CommandLineFlag -CommandText $CommandText -CommandWord 'git' -SubcommandPath @('worktree', 'remove') -FlagName '--force'
130
+ $operands = @(Get-CommandLineOperand -CommandText $CommandText -CommandWord 'git' -SubcommandPath @('worktree', 'remove'))
131
+
132
+ if ($operands.Count -gt 0) {
133
+ return $operands[0]
134
+ }
135
+ if ($hasForce) {
136
+ return '--force'
72
137
  }
73
138
  return $null
74
139
  }
@@ -76,11 +141,16 @@ function Get-ParallelWorktreeRemovalCommandPath {
76
141
  function Find-ParallelWorktreeItemRecord {
77
142
  <#
78
143
  .SYNOPSIS
79
- Locate the items[] record whose worktree_path matches the target path.
144
+ Locate the record whose worktree_path matches the target path.
80
145
  .PARAMETER Checkpoint
81
- Parsed parallel checkpoint, or $null when absent/unreadable.
146
+ Parsed checkpoint, or $null when absent/unreadable.
82
147
  .PARAMETER WorktreePath
83
148
  The target worktree path extracted from the command text.
149
+ .PARAMETER RecordArrayName
150
+ Name of the record array to scan. Defaults to 'items', the parallel
151
+ checkpoint's array, so every existing caller is unchanged; the epic branch
152
+ added for issue #688 passes 'features'. Parameterized rather than duplicated
153
+ so both topologies share one path-normalization implementation.
84
154
  .OUTPUTS
85
155
  System.Object or $null
86
156
  #>
@@ -90,14 +160,17 @@ function Find-ParallelWorktreeItemRecord {
90
160
  $Checkpoint,
91
161
 
92
162
  [AllowNull()]
93
- [string] $WorktreePath
163
+ [string] $WorktreePath,
164
+
165
+ [ValidateNotNullOrEmpty()]
166
+ [string] $RecordArrayName = 'items'
94
167
  )
95
168
 
96
169
  if ($null -eq $Checkpoint -or [string]::IsNullOrWhiteSpace($WorktreePath)) {
97
170
  return $null
98
171
  }
99
172
  $checkpointProps = @($Checkpoint.PSObject.Properties.Name)
100
- if ($checkpointProps -notcontains 'items') {
173
+ if ($checkpointProps -notcontains $RecordArrayName) {
101
174
  return $null
102
175
  }
103
176
 
@@ -105,7 +178,7 @@ function Find-ParallelWorktreeItemRecord {
105
178
 
106
179
  # Scan every recorded item for a worktree_path that matches the removal target;
107
180
  # path separators are normalized so Windows- and POSIX-style paths compare equal.
108
- foreach ($item in @($Checkpoint.items)) {
181
+ foreach ($item in @($Checkpoint.$RecordArrayName)) {
109
182
  $itemProps = @($item.PSObject.Properties.Name)
110
183
  if ($itemProps -notcontains 'worktree_path') {
111
184
  continue
@@ -203,7 +276,10 @@ function Invoke-ParallelWorktreeRemovalGateDecision {
203
276
  return Get-ParallelWorktreeGateAllowDecision
204
277
  }
205
278
 
206
- if ($commandText -notmatch '(?i)\bgit\s+worktree\s+remove\b') {
279
+ # Scope filter. The test is structural, so a relocating spelling such as
280
+ # 'git -C <dir> worktree remove <path>' is now in scope and quoted prose that merely
281
+ # mentions the phrase is not (issue #545). This is the same call the epic gate makes.
282
+ if (-not (Test-CommandLineInvocation -CommandText $commandText -CommandWord 'git' -SubcommandPath @('worktree', 'remove'))) {
207
283
  return Get-ParallelWorktreeGateAllowDecision
208
284
  }
209
285
 
@@ -224,6 +300,46 @@ function Invoke-ParallelWorktreeRemovalGateDecision {
224
300
  return Get-ParallelWorktreeGateAllowDecision
225
301
  }
226
302
 
303
+ # Epic-authorization branch (issue #688). It runs after the parallel allow above and
304
+ # before the manifest branch below, so neither of those decisions changes. An epic run
305
+ # records per-child state in the epic checkpoint's features[] array and never writes the
306
+ # parallel checkpoint, so before this branch every merged epic child worktree fell
307
+ # through to the deny even though enforce-epic-worktree-removal-gate.ps1 authorized the
308
+ # same call from that same record. The merge_status predicate is the one already used
309
+ # for parallel items, so the two topologies cannot drift apart on what counts as safe.
310
+ #
311
+ # Fail-closed is preserved: an absent, unreadable, or malformed epic checkpoint parses to
312
+ # $null, which yields no record, which is not allowed.
313
+ $epicCheckpointRaw = Get-ParallelWorktreeRemovalGateEpicCheckpointContent
314
+ $epicCheckpoint = $null
315
+ if (-not [string]::IsNullOrWhiteSpace($epicCheckpointRaw)) {
316
+ try {
317
+ $epicCheckpoint = $epicCheckpointRaw | ConvertFrom-Json -ErrorAction Stop
318
+ } catch {
319
+ $epicCheckpoint = $null
320
+ }
321
+ }
322
+
323
+ $featureRecord = Find-ParallelWorktreeItemRecord -Checkpoint $epicCheckpoint -WorktreePath $worktreePath -RecordArrayName 'features'
324
+ if (Test-ParallelWorktreeRemovalAllowed -ItemRecord $featureRecord) {
325
+ return Get-ParallelWorktreeGateAllowDecision
326
+ }
327
+
328
+ # Sanctioned-removal manifest branch (issue #635). It runs last, so a
329
+ # checkpoint-authorized removal still allows at the same decision point it does
330
+ # today and no transcript attribution changes.
331
+ #
332
+ # The coverage test is a PRESENCE test, deliberately not an authorization test. A
333
+ # target this checkpoint records at all is excluded from this branch regardless of
334
+ # that record's merge_status, so a removal the checkpoint does not authorize still
335
+ # reaches the unchanged deny below. This gate defines only the parallel checkpoint
336
+ # seam and has no epic-checkpoint seam, so its exclusion covers the items array
337
+ # only; the epic gate covers the features array with its own exclusion.
338
+ if (-not (Test-CleanupManifestCheckpointCoversPath -Checkpoint $checkpoint -RecordArrayName 'items' -WorktreePath $worktreePath) -and
339
+ (Test-CleanupWorktreeManifestAuthorizesRemoval -WorktreePath $worktreePath)) {
340
+ return Get-ParallelWorktreeGateAllowDecision
341
+ }
342
+
227
343
  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
344
  }
229
345