@danmoisan/drm-copilot-mcp 1.1.11 → 1.1.14

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 (66) hide show
  1. package/out/mcp-server.js +368 -209
  2. package/package.json +2 -2
  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-planner.md +18 -1
  6. package/resources/claude-customizations/.claude/agents/pr-author.md +15 -0
  7. package/resources/claude-customizations/.claude/hooks/enforce-completion-consistency.ps1 +16 -8
  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 +33 -37
  10. package/resources/claude-customizations/.claude/hooks/enforce-model-routing-receipt.ps1 +120 -5
  11. package/resources/claude-customizations/.claude/hooks/enforce-orchestration-preimplementation-gate-epic-scope.ps1 +127 -0
  12. package/resources/claude-customizations/.claude/hooks/enforce-orchestration-preimplementation-gate-helpers.ps1 +172 -24
  13. package/resources/claude-customizations/.claude/hooks/enforce-orchestration-preimplementation-gate.ps1 +14 -27
  14. package/resources/claude-customizations/.claude/hooks/enforce-parallel-worktree-removal-gate.ps1 +66 -5
  15. package/resources/claude-customizations/.claude/hooks/enforce-pr-author-skill-helpers.ps1 +134 -10
  16. package/resources/claude-customizations/.claude/hooks/enforce-pr-author-skill.epic-base-branch.ps1 +37 -8
  17. package/resources/claude-customizations/.claude/hooks/enforce-pr-author-skill.ps1 +3 -1
  18. package/resources/claude-customizations/.claude/hooks/enforce-prd-feature-before-planner-helpers.ps1 +332 -0
  19. package/resources/claude-customizations/.claude/hooks/enforce-prd-feature-before-planner.ps1 +211 -182
  20. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadius.psm1 +43 -6
  21. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusScheduling.psm1 +486 -0
  22. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusValidation.psm1 +5 -2
  23. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusWriteIntent.psm1 +446 -0
  24. package/resources/claude-customizations/.claude/lib/codex-routing/CodexDeployment.psm1 +1 -0
  25. package/resources/claude-customizations/.claude/lib/worktree-resolution/EpicScopeReadiness.psm1 +176 -0
  26. package/resources/claude-customizations/.claude/lib/worktree-resolution/EpicScopeResolution.psm1 +369 -0
  27. package/resources/claude-customizations/.claude/lib/worktree-resolution/WorktreeItemResolution.psm1 +392 -0
  28. package/resources/claude-customizations/.claude/lib/worktree-resolution/WorktreeResolution.psm1 +500 -0
  29. package/resources/claude-customizations/.claude/lib/worktree-resolution/WorktreeTargetResolution.psm1 +347 -0
  30. package/resources/claude-customizations/.claude/rules/orchestrator-state.md +27 -0
  31. package/resources/claude-customizations/.claude/rules/parallel-orchestration.md +150 -2
  32. package/resources/claude-customizations/.claude/rules/plan-acceptance-gates.md +5 -5
  33. package/resources/claude-customizations/.claude/skills/cleanup-merged-worktrees/SKILL.md +12 -0
  34. package/resources/claude-customizations/.claude/skills/epic-orchestrate/SKILL.md +14 -1
  35. package/resources/claude-customizations/.claude/skills/epic-plan/SKILL.md +15 -0
  36. package/resources/claude-customizations/.claude/skills/orchestrate/SKILL.md +4 -1
  37. package/resources/claude-customizations/.claude/skills/parallel-add/SKILL.md +32 -17
  38. package/resources/claude-customizations/.claude/skills/parallel-orchestrate/SKILL.md +39 -6
  39. package/resources/claude-customizations/.claude/skills/parallel-plan/SKILL.md +39 -10
  40. package/resources/claude-customizations/.claude/skills/pr-author/SKILL.md +15 -0
  41. package/resources/claude-customizations/.claude/skills/quota-throttling/SKILL.md +221 -0
  42. package/resources/claude-customizations/config/blast-radius.json +14 -1
  43. package/resources/claude-customizations/pack-manifests/core.json +11 -0
  44. package/resources/codex-and-agents-customizations/.agents/skills/codex-model-routing/SKILL.md +13 -14
  45. package/resources/codex-and-agents-customizations/.codex/config.toml +1 -1
  46. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-epic-merge-gate.ps1 +193 -0
  47. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-epic-planning-only.ps1 +15 -5
  48. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-orchestration-preimplementation-gate-epic-resolution.ps1 +493 -0
  49. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-orchestration-preimplementation-gate-epic-scope.ps1 +189 -0
  50. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-orchestration-preimplementation-gate-helpers.ps1 +172 -24
  51. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-orchestration-preimplementation-gate.ps1 +14 -27
  52. package/resources/codex-and-agents-customizations/.codex/scripts/Resolve-CodexDeployment.ps1 +87 -0
  53. package/resources/codex-and-agents-customizations/.codex/scripts/Resolve-CodexTopology.ps1 +96 -0
  54. package/resources/codex-and-agents-customizations/.codex/scripts/codex-routing-cli-common.ps1 +370 -0
  55. package/resources/codex-and-agents-customizations/.codex/scripts/epic-child-launch-contract.ps1 +475 -0
  56. package/resources/codex-and-agents-customizations/.codex/scripts/epic-child-launch-runtime.ps1 +445 -0
  57. package/resources/codex-and-agents-customizations/.codex/scripts/epic-child-persistence-runtime.ps1 +118 -0
  58. package/resources/codex-and-agents-customizations/.codex/scripts/epic-child-sandbox-preflight.ps1 +69 -0
  59. package/resources/codex-and-agents-customizations/.codex/scripts/launch-epic-child-wave.ps1 +471 -0
  60. package/resources/codex-and-agents-customizations/.codex/scripts/post-codex-worktree-session.ps1 +280 -0
  61. package/resources/codex-and-agents-customizations/.codex/scripts/resume-epic-child.ps1 +263 -0
  62. package/resources/codex-and-agents-customizations/.codex-variants/csharp-legacy/agents/csharp-typed-engineer.toml +2 -1
  63. package/resources/codex-and-agents-customizations/pack-manifests/core.json +20 -1
  64. package/resources/lib/codex-routing/CodexDeployment.psm1 +315 -0
  65. package/resources/lib/codex-routing/CodexTopology.psm1 +394 -0
  66. package/resources/powershell/PoshQC/settings/pester.runsettings.psd1 +37 -0
@@ -27,24 +27,42 @@ $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
+ # issues #663 and #713): interpolation characters outside single quotes; outside-quote
32
+ # characters (`<`, `>`, and the `#` comment introducer) outside any quote; and typographic
33
+ # quotes (U+2018 to U+201E) anywhere, because a PowerShell host reads them as quotes.
34
+ $script:InterpolationCommandCharacters = [char[]]@('$', '`')
35
+ $script:OutsideQuoteCommandCharacters = [char[]]@('>', '<', '#')
36
+ $script:TypographicQuoteCharacters = [char[]]@(0x2018, 0x2019, 0x201A, 0x201B, 0x201C, 0x201D, 0x201E)
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
42
61
  Splits a command line into segments on chain operators outside quotes.
43
62
  .DESCRIPTION
44
- Realizes D4 row 13. Quote state is tracked while scanning so a chain operator
45
- inside a quoted span does not split. The returned `Balanced` flag reports whether
46
- the scan ended outside every quote; unbalanced text is not splittable and the
47
- caller denies (D4 rows 11 and 13). Empty and whitespace-only segments are dropped.
63
+ Realizes D4 row 13. Quote state and POSIX backslash escapes (issue #710) are tracked, so a quoted or
64
+ escaped chain operator does not split. `Balanced` reports whether the scan ended outside every quote;
65
+ the caller denies unbalanced text (D4 rows 11 and 13). Empty and whitespace-only segments are dropped.
48
66
  .OUTPUTS
49
67
  System.Collections.Hashtable with keys `Balanced` (bool) and `Segments` (string[]).
50
68
  #>
@@ -55,8 +73,9 @@ function Split-OrchestrationCommandLine {
55
73
  $segments = [System.Collections.Generic.List[string]]::new()
56
74
  $current = [System.Text.StringBuilder]::new()
57
75
  $openQuote = [char]0
58
-
76
+ $escaped = $false
59
77
  foreach ($character in $CommandText.ToCharArray()) {
78
+ if ($escaped -or ($character -eq '\' -and $openQuote -ne "'")) { $escaped = -not $escaped; [void]$current.Append($character); continue }
60
79
  if ($openQuote -ne [char]0) {
61
80
  if ($character -eq $openQuote) {
62
81
  $openQuote = [char]0
@@ -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 issues #663 and #713, with quote state tracked as in
116
+ Split-OrchestrationCommandLine. `$` or backtick answers true outside quotes or inside
117
+ double quotes (single quotes keep it literal); `<`, `>`, or a `#` comment answers true
118
+ only outside a quoted span. A typographic quote (U+2018 to U+201E), a backslash before
119
+ any quote character, or a backslash inside a double-quoted span answers true anywhere:
120
+ a 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 or typographic quote moves a span boundary the scan cannot model (#663, #713).
131
+ if ($CommandText.Contains('\"') -or $CommandText.Contains("\'") -or $CommandText.IndexOfAny($script:TypographicQuoteCharacters) -ge 0) {
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
+ # outside-quote characters are judged only where the shell would honour them.
139
+ foreach ($character in $CommandText.ToCharArray()) {
140
+ if ($openQuote -ne "'" -and $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:OutsideQuoteCommandCharacters -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
@@ -251,21 +393,21 @@ function Test-ExemptOrchestrationSegmentToken {
251
393
  }
252
394
 
253
395
  if ($candidate.StartsWith('-')) {
254
- # The message option is the only modelled option on either subcommand. Rows
396
+ # Only the message and trailer options of commit are modelled (issue #713). Rows
255
397
  # 2, 5, 6, 8, and 10 all land here and deny, including a dash-leading
256
398
  # operand supplied without a preceding separator.
257
399
  if ($subcommand -cne 'commit') {
258
400
  return $false
259
401
  }
260
- if ($candidate -ceq '-m' -or $candidate -ceq '--message') {
261
- # The message value is the following token and is not a pathspec.
402
+ if ($candidate -ceq '-m' -or $candidate -ceq '--message' -or $candidate -ceq '--trailer') {
403
+ # The message or trailer value is the following token and is not a pathspec.
262
404
  $index += 2
263
405
  if ($index -gt $Token.Count) {
264
406
  return $false
265
407
  }
266
408
  continue
267
409
  }
268
- if ($candidate.StartsWith('--message=') -or
410
+ if ($candidate.StartsWith('--message=') -or $candidate.StartsWith('--trailer=') -or
269
411
  ($candidate.Length -gt 2 -and $candidate.StartsWith('-m'))) {
270
412
  $index++
271
413
  continue
@@ -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: `$` or backtick outside single quotes, `<`, `>`, or `#` outside quotes, any
468
+ # typographic quote, and unmodelled backslash escapes make the operand list untrustworthy.
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,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
+ # 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
+
22
26
  # Shared command-line parser (issue #545): per-segment scan text and structural matching.
23
27
  . (Join-Path $PSScriptRoot 'hook-command-scanner.ps1')
24
28
  . (Join-Path $PSScriptRoot 'hook-command-invocation.ps1')
@@ -261,33 +265,6 @@ function Get-CheckpointContent {
261
265
  return Get-Content -Raw -LiteralPath $script:CheckpointPath
262
266
  }
263
267
 
264
- # The two per-mode read seams (issue #554). Each takes its path from the fixed mode
265
- # table and never from a delegation's own text; an absent file returns an empty
266
- # string, which the readiness predicate then treats as a deny.
267
- function Get-EpicCheckpointContent {
268
- [CmdletBinding()]
269
- [OutputType([string])]
270
- param()
271
-
272
- $path = Get-OrchestrationDelegationCheckpointPath -Mode 'epic'
273
- if (-not (Test-Path -LiteralPath $path)) {
274
- return ''
275
- }
276
- return Get-Content -Raw -LiteralPath $path
277
- }
278
-
279
- function Get-ParallelCheckpointContent {
280
- [CmdletBinding()]
281
- [OutputType([string])]
282
- param()
283
-
284
- $path = Get-OrchestrationDelegationCheckpointPath -Mode 'parallel'
285
- if (-not (Test-Path -LiteralPath $path)) {
286
- return ''
287
- }
288
- return Get-Content -Raw -LiteralPath $path
289
- }
290
-
291
268
  function Get-OrchestrationPreimplementationGateAllowDecision {
292
269
  [CmdletBinding()]
293
270
  [OutputType([System.Collections.Specialized.OrderedDictionary])]
@@ -377,6 +354,7 @@ function Invoke-OrchestrationPreimplementationGateDecision {
377
354
  # default wording they have today.
378
355
  $mode = $script:OrchestrationDelegationDefaultMode
379
356
  $prompt = ''
357
+ $command = ''
380
358
  $filePath = Get-StringProperty -Value $toolInput -Name 'file_path'
381
359
  if ($filePath) {
382
360
  $normalized = ([string]$filePath) -replace '\\', '/'
@@ -406,6 +384,15 @@ function Invoke-OrchestrationPreimplementationGateDecision {
406
384
  Get-OrchestrationModeDenyReason -Mode $mode -Failure 'declared-checkpoint-path')
407
385
  }
408
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
+
409
396
  if ($mode -eq 'epic' -or $mode -eq 'parallel') {
410
397
  $isEpic = ($mode -eq 'epic')
411
398
  $injected = if ($isEpic) { 'EpicCheckpointRaw' } else { 'ParallelCheckpointRaw' }
@@ -41,6 +41,12 @@ Import-Module (Join-Path $PSScriptRoot '../lib/cleanup-manifest/CleanupWorktreeM
41
41
  . (Join-Path $PSScriptRoot 'hook-command-invocation.ps1')
42
42
  $script:ParallelCheckpointPath = 'artifacts/orchestration/parallel-orchestrator-state.json'
43
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'
44
50
  # Sanctioned-removal manifest location. Recorded here beside the checkpoint path so
45
51
  # every hook-read document this gate consults is named in one place; the module owns
46
52
  # the read itself.
@@ -64,6 +70,28 @@ function Get-ParallelWorktreeRemovalGateCheckpointContent {
64
70
  return (Get-Content -LiteralPath $script:ParallelCheckpointPath -Raw)
65
71
  }
66
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
+
67
95
  function Get-ParallelWorktreeRemovalCommandPath {
68
96
  <#
69
97
  .SYNOPSIS
@@ -113,11 +141,16 @@ function Get-ParallelWorktreeRemovalCommandPath {
113
141
  function Find-ParallelWorktreeItemRecord {
114
142
  <#
115
143
  .SYNOPSIS
116
- Locate the items[] record whose worktree_path matches the target path.
144
+ Locate the record whose worktree_path matches the target path.
117
145
  .PARAMETER Checkpoint
118
- Parsed parallel checkpoint, or $null when absent/unreadable.
146
+ Parsed checkpoint, or $null when absent/unreadable.
119
147
  .PARAMETER WorktreePath
120
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.
121
154
  .OUTPUTS
122
155
  System.Object or $null
123
156
  #>
@@ -127,14 +160,17 @@ function Find-ParallelWorktreeItemRecord {
127
160
  $Checkpoint,
128
161
 
129
162
  [AllowNull()]
130
- [string] $WorktreePath
163
+ [string] $WorktreePath,
164
+
165
+ [ValidateNotNullOrEmpty()]
166
+ [string] $RecordArrayName = 'items'
131
167
  )
132
168
 
133
169
  if ($null -eq $Checkpoint -or [string]::IsNullOrWhiteSpace($WorktreePath)) {
134
170
  return $null
135
171
  }
136
172
  $checkpointProps = @($Checkpoint.PSObject.Properties.Name)
137
- if ($checkpointProps -notcontains 'items') {
173
+ if ($checkpointProps -notcontains $RecordArrayName) {
138
174
  return $null
139
175
  }
140
176
 
@@ -142,7 +178,7 @@ function Find-ParallelWorktreeItemRecord {
142
178
 
143
179
  # Scan every recorded item for a worktree_path that matches the removal target;
144
180
  # path separators are normalized so Windows- and POSIX-style paths compare equal.
145
- foreach ($item in @($Checkpoint.items)) {
181
+ foreach ($item in @($Checkpoint.$RecordArrayName)) {
146
182
  $itemProps = @($item.PSObject.Properties.Name)
147
183
  if ($itemProps -notcontains 'worktree_path') {
148
184
  continue
@@ -264,6 +300,31 @@ function Invoke-ParallelWorktreeRemovalGateDecision {
264
300
  return Get-ParallelWorktreeGateAllowDecision
265
301
  }
266
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
+
267
328
  # Sanctioned-removal manifest branch (issue #635). It runs last, so a
268
329
  # checkpoint-authorized removal still allows at the same decision point it does
269
330
  # today and no transcript attribution changes.
@@ -15,10 +15,13 @@
15
15
  payload-reader migration (issue #501) added the module import, the envelope-anomaly
16
16
  mapping, and the entry-point seam. The split is a pure move with no behaviour change.
17
17
 
18
- Both helpers read the script-scoped configuration variables the parent hook assigns
19
- ($script:PrContextArtifactPath, $script:OrchestratorStateCheckpointPath) and call the
20
- parent hook's injectable read seams, so this file is only ever dot-sourced from that
21
- hook and never invoked on its own.
18
+ Both helpers read the script-scoped configuration the parent hook declares and call the
19
+ parent hook's injectable read seams, so this file is only ever dot-sourced from that hook
20
+ and never invoked on its own. $script:PrContextArtifactPath is a process-directory-relative
21
+ artifact path and stays one. $script:OrchestratorStateCheckpointPath is different after
22
+ issue #673: the parent declares it null, and Get-PrAuthorBypassReason assigns it the
23
+ absolute checkpoint path of the worktree identity resolution selected, so every later
24
+ reader receives one resolved path rather than deriving its own.
22
25
 
23
26
  .NOTES
24
27
  Compatible with PowerShell 7+. No external module dependencies.
@@ -30,6 +33,92 @@ param()
30
33
  # so this file keeps working when a test dot-sources it directly.
31
34
  . (Join-Path $PSScriptRoot 'hook-command-scanner.ps1')
32
35
  . (Join-Path $PSScriptRoot 'hook-command-invocation.ps1')
36
+ # Worktree target resolution (issue #687). The checkpoint this gate validates must belong to
37
+ # the item the call is about. Reading a session-root-relative path made that the item whose
38
+ # worktree happened to occupy the session root, so in a parallel or epic topology the gate
39
+ # could return allow on the strength of a sibling item's state -- a false green, which is
40
+ # worse than a false denial because it reports success for a lifecycle it never checked.
41
+ Import-Module (Join-Path $PSScriptRoot '../lib/worktree-resolution/WorktreeResolution.psm1') -Force -ErrorAction Stop
42
+ Import-Module (Join-Path $PSScriptRoot '../lib/worktree-resolution/WorktreeTargetResolution.psm1') -Force -ErrorAction Stop
43
+ # Portable-identity resolution (issue #673). A path signal cannot select a worktree: a merged
44
+ # feature folder exists in every checkout branched from main, so it places a call in many at
45
+ # once. The item is identified by its canonical issue number and its branch instead.
46
+ Import-Module (Join-Path $PSScriptRoot '../lib/worktree-resolution/WorktreeItemResolution.psm1') -Force -ErrorAction Stop
47
+ # Epic scope (issue #663): the epic integration pull request is gated against the epic checkpoint.
48
+ Import-Module (Join-Path $PSScriptRoot '../lib/worktree-resolution/EpicScopeResolution.psm1') -Force -ErrorAction Stop
49
+ Import-Module (Join-Path $PSScriptRoot '../lib/worktree-resolution/EpicScopeReadiness.psm1') -Force -ErrorAction Stop
50
+
51
+ function Resolve-PrAuthorWorktreeTarget {
52
+ <#
53
+ .SYNOPSIS
54
+ Resolve the call's target worktree. Tests mock this function (resolution seam).
55
+ .DESCRIPTION
56
+ A seam rather than a direct call because the resolver reads real git state: the
57
+ worktree list, each registration's branch, and the filesystem. A test that drove it
58
+ directly would depend on the machine's live worktrees and would pass or fail
59
+ according to what other sessions happen to have checked out, which the repository's
60
+ determinism policy forbids. Mocking here lets a test state the resolution outcome it
61
+ is exercising and nothing else.
62
+ .OUTPUTS
63
+ The target result object from Resolve-WorktreeItemTarget.
64
+ #>
65
+ [CmdletBinding()]
66
+ param(
67
+ [Parameter(Mandatory)]
68
+ [string] $CommandText
69
+ )
70
+
71
+ return (Resolve-WorktreeItemTarget -Text $CommandText -SessionRoot (Get-Location).Path)
72
+ }
73
+
74
+ function Get-PrAuthorTargetCheckpointResolution {
75
+ <#
76
+ .SYNOPSIS
77
+ Resolve the checkpoint path this call must be validated against.
78
+ .DESCRIPTION
79
+ Resolves the target worktree from the command text by portable identity and maps the
80
+ four resolution states onto a checkpoint path or a deny reason:
81
+
82
+ SessionRoot -> that worktree's own checkpoint, composed absolutely.
83
+ OtherWorktree -> that worktree's own checkpoint, composed the same way.
84
+ NoTarget -> deny, carrying the code Get-WorktreeResolutionNoTargetReasonCode
85
+ returns. pr-author passes --head on every gh pr create, so a call
86
+ reaching here omitted the explicit target; the remedy is to supply
87
+ it, never to fall back to whichever checkpoint occupies the
88
+ session root.
89
+ Ambiguous -> deny, carrying the code
90
+ Get-WorktreeResolutionAmbiguityReasonCode returns.
91
+
92
+ The two resolved states share one branch because the path is now composed the same
93
+ way for both: identity selects the worktree, and the checkpoint is read beneath it.
94
+ A session-root result is not a special case, it is the case where identity happened
95
+ to select the worktree the process is already running in.
96
+
97
+ Returning a deny for the two unresolved states is what keeps the gate honest: it
98
+ refuses to answer rather than answering from unrelated state. Neither reason-code
99
+ literal appears in this file; both come from the accessors named above.
100
+ .OUTPUTS
101
+ System.Collections.Specialized.OrderedDictionary with CheckpointPath and Reason.
102
+ #>
103
+ [CmdletBinding()]
104
+ [OutputType([System.Collections.Specialized.OrderedDictionary])]
105
+ param(
106
+ [Parameter(Mandatory)]
107
+ [string] $CommandText
108
+ )
109
+
110
+ $target = Resolve-PrAuthorWorktreeTarget -CommandText $CommandText
111
+
112
+ switch ($target.Status) {
113
+ { $_ -in @('SessionRoot', 'OtherWorktree') } {
114
+ return [ordered]@{ CheckpointPath = (Get-WorktreeItemCheckpointPath -WorktreeRoot $target.WorktreeRoot); Reason = $null }
115
+ }
116
+ default {
117
+ $reason = "$($target.ReasonCode): $($target.Detail) The pr-author gate will not validate this call against the session root's checkpoint, because that checkpoint may belong to a different item. Pass --head <branch> on the gh pr create command so the call names its own target."
118
+ return [ordered]@{ CheckpointPath = $null; Reason = $reason }
119
+ }
120
+ }
121
+ }
33
122
 
34
123
  function Test-PrAuthorReceiptVerification {
35
124
  <#
@@ -55,6 +144,12 @@ function Test-PrAuthorReceiptVerification {
55
144
  with Write access to artifacts/ can replace the body file and the receipt together.
56
145
  .PARAMETER CommandText
57
146
  The Bash command text containing the --body-file argument.
147
+ .PARAMETER CheckpointPath
148
+ The absolute checkpoint path identity resolution selected, passed through to check 6
149
+ so that check reads the resolved worktree's checkpoint rather than deriving its own.
150
+ .PARAMETER EpicScope
151
+ Optional. The epic-scope result Get-PrAuthorBypassReason resolved (issue #663), passed
152
+ through to check 6 so the call is resolved once; $null outside epic scope.
58
153
  .OUTPUTS
59
154
  System.String or $null
60
155
  #>
@@ -62,7 +157,13 @@ function Test-PrAuthorReceiptVerification {
62
157
  [OutputType([string])]
63
158
  param(
64
159
  [Parameter(Mandatory)]
65
- [string] $CommandText
160
+ [string] $CommandText,
161
+
162
+ [Parameter(Mandatory)]
163
+ [string] $CheckpointPath,
164
+
165
+ [AllowNull()]
166
+ [object] $EpicScope
66
167
  )
67
168
 
68
169
  # Check 1: the --body-file argument must match the canonical artifacts/pr_body_<N>.md pattern.
@@ -133,7 +234,7 @@ function Test-PrAuthorReceiptVerification {
133
234
  }
134
235
 
135
236
  # Check 6: under epic_mode, gh pr create must carry a matching --base override.
136
- $epicBaseBranchReason = Test-EpicBaseBranchOverride -CommandText $CommandText
237
+ $epicBaseBranchReason = Test-EpicBaseBranchOverride -CommandText $CommandText -CheckpointPath $CheckpointPath -EpicScope $EpicScope
137
238
  if ($epicBaseBranchReason) {
138
239
  return $epicBaseBranchReason
139
240
  }
@@ -234,13 +335,36 @@ function Get-PrAuthorBypassReason {
234
335
  return "PR_CONTEXT_MISSING: ``$script:PrContextArtifactPath`` is absent. Run ``mcp__drm-copilot__collect_pr_context`` before creating or editing the PR body."
235
336
  }
236
337
 
338
+ # Epic scope (issue #663): when the call's --head equals the epic checkpoint's
339
+ # integration_branch, the integration pull request is gated by the epic PR-creation
340
+ # readiness predicate on artifacts/orchestration/epic-orchestrator-state.json, and no
341
+ # per-feature checkpoint is resolved or read. Resolved once; check 6 reuses the result.
342
+ $epicScope = $null
343
+ if ($hasBodyFile -and $ContextExists) {
344
+ $epicScope = Resolve-EpicScopeCheckpoint -Text $CommandText -SessionRoot (Get-Location).Path
345
+ }
346
+
237
347
  # Orchestrator-state preflight: runs inside this same PreToolUse hook (so it cannot be
238
348
  # bypassed by invoking gh pr create/edit directly) before receipt verification.
239
- if ($hasBodyFile -and $ContextExists) {
240
- $preflightResult = Invoke-OrchestratorStatePreflight -CheckpointPath $script:OrchestratorStateCheckpointPath
349
+ if ($null -ne $epicScope -and $epicScope.IsEpicScope) {
350
+ $failure = Get-EpicPrCreationReadinessFailure -Checkpoint $epicScope.Checkpoint -HeadBranch $epicScope.Branch
351
+ if ($failure) {
352
+ return "ORCHESTRATOR_STATE_PREFLIGHT_FAILED: this epic-scope pull request was evaluated against $($epicScope.CheckpointPath), and the failed readiness predicate is '$failure'."
353
+ }
354
+ $script:OrchestratorStateCheckpointPath = $epicScope.CheckpointPath
355
+ } elseif ($hasBodyFile -and $ContextExists) {
356
+ $resolution = Get-PrAuthorTargetCheckpointResolution -CommandText $CommandText
357
+ if ($resolution.Reason) {
358
+ return $resolution.Reason
359
+ }
360
+ # Assigned once here and read by every later consumer, so the preflight, the receipt
361
+ # verifier, and the epic base-branch check cannot disagree about which item is gated.
362
+ $script:OrchestratorStateCheckpointPath = $resolution.CheckpointPath
363
+ $checkpointPath = $script:OrchestratorStateCheckpointPath
364
+ $preflightResult = Invoke-OrchestratorStatePreflight -CheckpointPath $checkpointPath
241
365
  if ($preflightResult.HasErrors) {
242
366
  $preflightSummary = if ([string]::IsNullOrWhiteSpace($preflightResult.ErrorText)) {
243
- "checkpoint missing at $script:OrchestratorStateCheckpointPath"
367
+ "checkpoint missing at $checkpointPath"
244
368
  } else {
245
369
  $preflightResult.ErrorText
246
370
  }
@@ -250,7 +374,7 @@ function Get-PrAuthorBypassReason {
250
374
 
251
375
  # Receipt verification: extends, and does not replace, the previously-allowed path.
252
376
  if ($hasBodyFile -and $ContextExists) {
253
- $receiptReason = Test-PrAuthorReceiptVerification -CommandText $CommandText
377
+ $receiptReason = Test-PrAuthorReceiptVerification -CommandText $CommandText -CheckpointPath $script:OrchestratorStateCheckpointPath -EpicScope $epicScope
254
378
  if ($receiptReason) {
255
379
  return $receiptReason
256
380
  }