@danmoisan/drm-copilot-mcp 1.0.27 → 1.1.0

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 (34) hide show
  1. package/out/mcp-server.js +0 -1
  2. package/package.json +1 -1
  3. package/resources/claude-customizations/.claude/hooks/check-powershell-test-purity.ps1 +17 -21
  4. package/resources/claude-customizations/.claude/hooks/check-python-test-purity.ps1 +17 -19
  5. package/resources/claude-customizations/.claude/hooks/enforce-checkpoint-monotonic.ps1 +17 -19
  6. package/resources/claude-customizations/.claude/hooks/enforce-completion-consistency.ps1 +18 -19
  7. package/resources/claude-customizations/.claude/hooks/enforce-discovery-artifact-gate.ps1 +18 -19
  8. package/resources/claude-customizations/.claude/hooks/enforce-epic-invocation-origin.ps1 +54 -32
  9. package/resources/claude-customizations/.claude/hooks/enforce-epic-merge-gate.ps1 +64 -20
  10. package/resources/claude-customizations/.claude/hooks/enforce-epic-wave-barrier.ps1 +51 -22
  11. package/resources/claude-customizations/.claude/hooks/enforce-epic-worktree-removal-gate.ps1 +56 -19
  12. package/resources/claude-customizations/.claude/hooks/enforce-evidence-locations.ps1 +71 -28
  13. package/resources/claude-customizations/.claude/hooks/enforce-feature-folder-order.ps1 +68 -23
  14. package/resources/claude-customizations/.claude/hooks/enforce-mermaid-validation.ps1 +36 -24
  15. package/resources/claude-customizations/.claude/hooks/enforce-model-routing-receipt.ps1 +20 -22
  16. package/resources/claude-customizations/.claude/hooks/enforce-orchestration-preimplementation-gate.ps1 +59 -14
  17. package/resources/claude-customizations/.claude/hooks/enforce-parallel-abandon-gate.ps1 +17 -20
  18. package/resources/claude-customizations/.claude/hooks/enforce-parallel-cohort-barrier-helpers.ps1 +278 -0
  19. package/resources/claude-customizations/.claude/hooks/enforce-parallel-cohort-barrier.ps1 +55 -271
  20. package/resources/claude-customizations/.claude/hooks/enforce-parallel-drift-gate.ps1 +57 -22
  21. package/resources/claude-customizations/.claude/hooks/enforce-parallel-worktree-removal-gate.ps1 +56 -19
  22. package/resources/claude-customizations/.claude/hooks/enforce-powershell-batch-budget.ps1 +70 -27
  23. package/resources/claude-customizations/.claude/hooks/enforce-pr-author-skill-helpers.ps1 +228 -0
  24. package/resources/claude-customizations/.claude/hooks/enforce-pr-author-skill.ps1 +68 -225
  25. package/resources/claude-customizations/.claude/hooks/enforce-prd-feature-before-planner.ps1 +161 -34
  26. package/resources/claude-customizations/.claude/hooks/enforce-promotion-mcp-only.ps1 +59 -22
  27. package/resources/claude-customizations/.claude/hooks/enforce-python-batch-budget.ps1 +70 -27
  28. package/resources/claude-customizations/.claude/hooks/validate-bash.ps1 +32 -18
  29. package/resources/claude-customizations/.claude/lib/hook-payload/HookPayload.psm1 +494 -0
  30. package/resources/claude-customizations/.claude/rules/parallel-orchestration.md +63 -0
  31. package/resources/claude-customizations/config/blast-radius.json +9 -3
  32. package/resources/claude-customizations/pack-manifests/core.json +3 -0
  33. package/resources/codex-and-agents-customizations/.codex/config.toml +1 -1
  34. package/resources/powershell/PoshQC/settings/pester.runsettings.psd1 +15 -0
@@ -4,9 +4,9 @@
4
4
 
5
5
  .DESCRIPTION
6
6
  This script is invoked by the Claude Code PreToolUse hook before any Write or Edit
7
- operation. It reads the tool input from the CLAUDE_TOOL_INPUT environment variable
8
- (JSON with 'file_path' plus 'content' for Write, or 'old_string'/'new_string' for
9
- Edit) and applies two independent gates:
7
+ operation. It acquires the hook payload through the shared reader and reads the
8
+ envelope's nested tool_input ('file_path' plus 'content' for Write, or
9
+ 'old_string'/'new_string' for Edit), then applies two independent gates:
10
10
 
11
11
  1. Syntax gate. On a Write of a '.mmd'/'.mermaid' file, the whole file is one
12
12
  diagram. On a Write of a Markdown file, every fenced ```mermaid block is a
@@ -21,8 +21,8 @@
21
21
  Blocking a valid diagram is worse than missing an invalid one, so the hook declines
22
22
  to judge rather than rejecting whenever it cannot classify content confidently:
23
23
 
24
- - empty, absent, or unparseable CLAUDE_TOOL_INPUT: allow;
25
- - missing 'file_path', or a path outside the '.mmd'/'.mermaid'/Markdown scope: allow;
24
+ - missing 'file_path' inside a well-formed tool_input, or a path outside the
25
+ '.mmd'/'.mermaid'/Markdown scope: allow;
26
26
  - the validation module absent from disk: allow;
27
27
  - an Edit payload (the syntax check needs the whole file, which an
28
28
  old_string/new_string fragment cannot supply): allow;
@@ -32,13 +32,17 @@
32
32
  - a fence immediately preceded by '<!-- mermaid-validator: ignore -->': skip that
33
33
  block, and only that block.
34
34
 
35
- DELIBERATE DIVERGENCE FROM enforce-evidence-locations.ps1: that hook throws on
36
- malformed CLAUDE_TOOL_INPUT JSON and its entry point exits 1. This hook allows
37
- instead. The difference is intentional and must not be "fixed" into a hard failure:
38
- a content gate that hard-fails on input it cannot parse converts an unparseable
39
- payload into a blocked write, which is the false-positive failure mode this feature
40
- exists to avoid. The evidence-location hook gates a path, which is always parseable
41
- when present; this hook gates content, which is not.
35
+ ENVELOPE VERSUS CONTENT (revised by issue #501). The hook now distinguishes two
36
+ kinds of unreadability that the original note conflated. An ENVELOPE-level anomaly
37
+ -- empty payload on every transport, unparseable envelope JSON, or a payload with
38
+ no usable tool_input -- means the PreToolUse contract itself drifted, which is the
39
+ exact defect #501 fixed and which must fail loudly rather than silently allow; the
40
+ hook therefore denies. A CONTENT-level judgement remains permissive, and that half
41
+ of the original note stands unchanged and must not be "fixed" into a hard failure:
42
+ an Edit fragment, a Markdown file with no fence, an absent validation module, and a
43
+ diagram the classifier cannot classify all still allow, because blocking a valid
44
+ diagram is worse than missing an invalid one. The prior blanket allow-on-unparseable
45
+ made the gate inert against the payload the harness actually sends.
42
46
 
43
47
  The extension scope check runs before any content scan, so a write outside the
44
48
  Mermaid scope pays only the JSON parse.
@@ -55,6 +59,8 @@ param()
55
59
 
56
60
  Set-StrictMode -Version Latest
57
61
 
62
+ Import-Module (Join-Path $PSScriptRoot '../lib/hook-payload/HookPayload.psm1') -Force
63
+
58
64
  $script:MermaidModulePath = Join-Path -Path $PSScriptRoot -ChildPath '../lib/mermaid/MermaidValidation.psm1'
59
65
  $script:MermaidSkillPointer = 'See .claude/skills/mermaid-diagram/SKILL.md.'
60
66
  $script:MermaidSyncPointer = 'Change it through the Mermaid Chart sync workflow in VS Code (Mermaid Chart extension: Sync Diagram with Mermaid, then Review Mermaid Sync) and pull the synced result instead of hand-editing. See .claude/rules/mermaid.md.'
@@ -288,7 +294,7 @@ function Invoke-MermaidValidationDecision {
288
294
  business (out of scope, unparseable, or a Markdown file with no fence), which the
289
295
  entry point treats as a silent allow.
290
296
  .PARAMETER ToolInputRaw
291
- The raw JSON string from $env:CLAUDE_TOOL_INPUT.
297
+ The raw JSON hook payload acquired by Read-ClaudeHookRawPayload.
292
298
  #>
293
299
  [CmdletBinding()]
294
300
  [OutputType([System.Collections.Specialized.OrderedDictionary])]
@@ -298,16 +304,18 @@ function Invoke-MermaidValidationDecision {
298
304
  [string] $ToolInputRaw
299
305
  )
300
306
 
301
- if ([string]::IsNullOrWhiteSpace($ToolInputRaw)) { return $null }
302
-
303
- try {
304
- $toolInput = $ToolInputRaw | ConvertFrom-Json -ErrorAction Stop
305
- } catch {
306
- # Fail open. See the DELIBERATE DIVERGENCE note in this file's header before
307
- # changing this to a throw.
308
- return $null
307
+ # Envelope-level anomaly: fail closed. See the ENVELOPE VERSUS CONTENT note in
308
+ # this file's header before changing this back to a silent allow.
309
+ $envelope = Resolve-ClaudeHookToolInput -Raw $ToolInputRaw
310
+ if (-not $envelope.IsValid) {
311
+ return Get-MermaidDenyDecision -Reason (
312
+ 'MERMAID_VALIDATION_BLOCKED: payload anomaly - ' +
313
+ (Get-ClaudeHookPayloadAnomalyReason -Anomaly $envelope.Anomaly) +
314
+ '. The gate fails closed on an envelope it cannot read.')
309
315
  }
310
316
 
317
+ $toolInput = $envelope.Value
318
+
311
319
  $filePath = [string](Get-MermaidToolInputField -InputObject $toolInput -Name 'file_path')
312
320
  if ([string]::IsNullOrWhiteSpace($filePath)) { return $null }
313
321
 
@@ -362,16 +370,20 @@ function Invoke-MermaidValidationEntryPoint {
362
370
  output stream as the JSON, so the caller would consume the JSON instead of
363
371
  printing it and the decision would never reach Claude Code.
364
372
  .PARAMETER ToolInputRaw
365
- The raw JSON string from $env:CLAUDE_TOOL_INPUT.
373
+ The raw JSON hook payload acquired by Read-ClaudeHookRawPayload.
366
374
  #>
367
375
  [CmdletBinding()]
368
376
  [OutputType([string])]
369
377
  param(
370
378
  [AllowEmptyString()]
371
379
  [AllowNull()]
372
- [string] $ToolInputRaw = $env:CLAUDE_TOOL_INPUT
380
+ [string] $ToolInputRaw
373
381
  )
374
382
 
383
+ if (-not $PSBoundParameters.ContainsKey('ToolInputRaw')) {
384
+ $ToolInputRaw = Read-ClaudeHookRawPayload
385
+ }
386
+
375
387
  $decision = Invoke-MermaidValidationDecision -ToolInputRaw $ToolInputRaw
376
388
  if ($null -eq $decision) { return }
377
389
 
@@ -383,7 +395,7 @@ if ($MyInvocation.InvocationName -eq '.') {
383
395
  return
384
396
  }
385
397
 
386
- Invoke-MermaidValidationEntryPoint -ToolInputRaw $env:CLAUDE_TOOL_INPUT
398
+ Invoke-MermaidValidationEntryPoint -ToolInputRaw (Read-ClaudeHookRawPayload)
387
399
 
388
400
  # Exit 0 on allow and on deny alike: the decision travels in the JSON, never in the
389
401
  # exit code.
@@ -5,7 +5,7 @@
5
5
 
6
6
  .DESCRIPTION
7
7
  Invoked by the Claude Code PreToolUse hook on the Agent (Task) tool. Reads
8
- tool input JSON from the CLAUDE_TOOL_INPUT environment variable and the
8
+ tool input JSON from the the envelope's nested tool_input environment variable and the
9
9
  orchestrator checkpoint from artifacts/orchestration/orchestrator-state.json.
10
10
 
11
11
  The hook enforces presence only: it cannot read the delegate's chosen
@@ -32,6 +32,8 @@
32
32
  [CmdletBinding()]
33
33
  param()
34
34
 
35
+
36
+ Import-Module (Join-Path $PSScriptRoot '../lib/hook-payload/HookPayload.psm1') -Force
35
37
  function Get-ModelRoutingCheckpoint {
36
38
  <#
37
39
  .SYNOPSIS
@@ -118,7 +120,7 @@ function Test-ModelRoutingReceiptPresent {
118
120
  function Invoke-ModelRoutingReceiptDecision {
119
121
  <#
120
122
  .SYNOPSIS
121
- Parses CLAUDE_TOOL_INPUT and returns an allow-or-block decision object.
123
+ Parses the envelope's nested tool_input and returns an allow-or-block decision object.
122
124
  #>
123
125
  [CmdletBinding()]
124
126
  [OutputType([System.Collections.Specialized.OrderedDictionary])]
@@ -128,21 +130,23 @@ function Invoke-ModelRoutingReceiptDecision {
128
130
 
129
131
  $allow = [ordered]@{ hookSpecificOutput = [ordered]@{ hookEventName = 'PreToolUse'; permissionDecision = 'allow' } }
130
132
 
131
- # Empty or absent tool input is not a delegation this hook can gate.
132
- if (-not $ToolInputRaw) {
133
- return $allow
134
- }
135
-
136
- # Malformed tool-input JSON is allowed through gracefully; this hook is a
137
- # deterrent, not the authoritative validator.
138
- try {
139
- $toolInput = $ToolInputRaw | ConvertFrom-Json -ErrorAction Stop
140
- }
141
- catch {
142
- return $allow
133
+ # Envelope-level anomalies fail closed (issue #501): an unreadable envelope means
134
+ # the PreToolUse contract drifted, which is exactly the condition that made every
135
+ # gate in this runtime inert, so it must deny rather than pass through.
136
+ $envelope = Resolve-ClaudeHookToolInput -Raw $ToolInputRaw
137
+ if (-not $envelope.IsValid) {
138
+ return [ordered]@{
139
+ hookSpecificOutput = [ordered]@{
140
+ hookEventName = 'PreToolUse'
141
+ permissionDecision = 'deny'
142
+ permissionDecisionReason = 'MODEL_ROUTING_RECEIPT_BLOCKED: payload anomaly - ' +
143
+ (Get-ClaudeHookPayloadAnomalyReason -Anomaly $envelope.Anomaly) +
144
+ '. The gate fails closed on an envelope it cannot read.'
145
+ }
146
+ }
143
147
  }
144
148
 
145
- $subagent = [string]$toolInput.subagent_type
149
+ $subagent = Get-ClaudeHookToolInputString -ToolInput $envelope.Value -Name 'subagent_type'
146
150
 
147
151
  # Only the gated Agent-tool delegates are receipt-checked; any other
148
152
  # subagent_type (including orchestrator) passes through.
@@ -169,13 +173,7 @@ if ($MyInvocation.InvocationName -eq '.') {
169
173
  return
170
174
  }
171
175
 
172
- try {
173
- $decision = Invoke-ModelRoutingReceiptDecision -ToolInputRaw $env:CLAUDE_TOOL_INPUT
174
- }
175
- catch {
176
- Write-Error $_
177
- exit 1
178
- }
176
+ $decision = Invoke-ModelRoutingReceiptDecision -ToolInputRaw (Read-ClaudeHookRawPayload)
179
177
 
180
178
  $decision | ConvertTo-Json -Compress -Depth 5 | Write-Output
181
179
 
@@ -5,6 +5,8 @@
5
5
  [CmdletBinding()]
6
6
  param()
7
7
 
8
+
9
+ Import-Module (Join-Path $PSScriptRoot '../lib/hook-payload/HookPayload.psm1') -Force
8
10
  $script:CheckpointPath = 'artifacts/orchestration/orchestrator-state.json'
9
11
 
10
12
  function ConvertFrom-CheckpointJson {
@@ -164,19 +166,23 @@ function Invoke-OrchestrationPreimplementationGateDecision {
164
166
  [CmdletBinding()]
165
167
  [OutputType([System.Collections.Specialized.OrderedDictionary])]
166
168
  param(
169
+ [AllowNull()]
170
+ [AllowEmptyString()]
167
171
  [string] $ToolInputRaw,
172
+
168
173
  [string] $CheckpointRaw
169
174
  )
170
175
 
171
- if (-not $ToolInputRaw) {
172
- return Get-OrchestrationPreimplementationGateAllowDecision
173
- }
174
- try {
175
- $toolInput = $ToolInputRaw | ConvertFrom-Json -ErrorAction Stop
176
- } catch {
177
- throw "enforce-orchestration-preimplementation-gate hook received malformed JSON in CLAUDE_TOOL_INPUT: $_"
176
+ $payload = Resolve-ClaudeHookToolInput -Raw $ToolInputRaw
177
+ if (-not $payload.IsValid) {
178
+ return Get-OrchestrationPreimplementationGateBlockDecision -Reason (
179
+ 'PREIMPLEMENTATION_GATE_BLOCKED: payload anomaly - ' +
180
+ (Get-ClaudeHookPayloadAnomalyReason -Anomaly $payload.Anomaly) +
181
+ '. The gate fails closed on an envelope it cannot read.')
178
182
  }
179
183
 
184
+ $toolInput = $payload.Value
185
+
180
186
  $requiresReadyCheckpoint = $false
181
187
  $filePath = Get-StringProperty -Value $toolInput -Name 'file_path'
182
188
  if ($filePath) {
@@ -210,16 +216,55 @@ function Invoke-OrchestrationPreimplementationGateDecision {
210
216
  return Get-OrchestrationPreimplementationGateBlockDecision -Reason 'PREIMPLEMENTATION_GATE_BLOCKED: Implementation operations require artifacts/orchestration/orchestrator-state.json to contain issue number, feature folder, route metadata, lifecycle readiness, and checkpoint state before implementation begins.'
211
217
  }
212
218
 
219
+ function Invoke-OrchestrationPreimplementationGateEntryPoint {
220
+ <#
221
+ .SYNOPSIS
222
+ Runs the hook decision and returns the process exit code.
223
+ .DESCRIPTION
224
+ Acquires the payload through the shared reader unless the caller supplies
225
+ one, emits the compact decision JSON, and returns 0. It never returns 1:
226
+ exit 1 is non-blocking for PreToolUse, so every anomaly is already a deny
227
+ decision by the time control reaches here. The function does not call exit;
228
+ the thin tail converts the returned code into a process exit.
229
+ .PARAMETER ToolInputRaw
230
+ Optional pre-acquired payload text. When omitted the ReadPayload seam runs.
231
+ .PARAMETER ReadPayload
232
+ Seam for payload acquisition, so tests can drive the empty-on-all-transports
233
+ case without touching a console.
234
+ .OUTPUTS
235
+ System.Int32
236
+ #>
237
+ [CmdletBinding()]
238
+ [OutputType([int])]
239
+ param(
240
+ [AllowNull()]
241
+ [AllowEmptyString()]
242
+ [string] $ToolInputRaw,
243
+
244
+ [scriptblock] $ReadPayload = { Read-ClaudeHookRawPayload }
245
+ )
246
+
247
+ if (-not $PSBoundParameters.ContainsKey('ToolInputRaw')) {
248
+ $ToolInputRaw = [string](& $ReadPayload)
249
+ }
250
+
251
+ $decision = Invoke-OrchestrationPreimplementationGateDecision -ToolInputRaw $ToolInputRaw
252
+ $decision | ConvertTo-Json -Compress -Depth 5 | Write-Output
253
+
254
+ return 0
255
+ }
256
+
257
+ # Guard allows dot-sourcing in tests without executing the entrypoint.
213
258
  if ($MyInvocation.InvocationName -eq '.') {
214
259
  return
215
260
  }
216
261
 
217
- try {
218
- $decision = Invoke-OrchestrationPreimplementationGateDecision -ToolInputRaw $env:CLAUDE_TOOL_INPUT
219
- } catch {
220
- Write-Error $_
221
- exit 1
262
+ # The entry point returns its [int] exit code as the last pipeline element and the
263
+ # decision JSON before it. `exit (<call>)` would capture BOTH into the exit
264
+ # expression and emit nothing, so the decision is written explicitly here first.
265
+ $entryPointResult = @(Invoke-OrchestrationPreimplementationGateEntryPoint)
266
+ if ($entryPointResult.Count -gt 1) {
267
+ $entryPointResult[0..($entryPointResult.Count - 2)] | Write-Output
222
268
  }
223
269
 
224
- $decision | ConvertTo-Json -Compress -Depth 5 | Write-Output
225
- exit 0
270
+ exit ([int]$entryPointResult[-1])
@@ -5,7 +5,8 @@
5
5
 
6
6
  .DESCRIPTION
7
7
  Invoked by the Claude Code PreToolUse hook on the "Bash" matcher before any Bash
8
- command runs. Reads CLAUDE_TOOL_INPUT, extracts the command text, and matches it
8
+ command runs. Reads the hook payload through the shared reader, extracts the
9
+ command text from the envelope's nested tool_input, and matches it
9
10
  against the two tokens declared at the top of this file: the abandon disposition
10
11
  token and the confirmation marker. A command carrying the abandon disposition token
11
12
  without the confirmation marker in the SAME command is denied with reason prefix
@@ -32,6 +33,8 @@
32
33
  [CmdletBinding()]
33
34
  param()
34
35
 
36
+
37
+ Import-Module (Join-Path $PSScriptRoot '../lib/hook-payload/HookPayload.psm1') -Force
35
38
  # The two tokens this gate matches on. These are the ONLY places either token literal
36
39
  # appears in this file; the seam test extracts the consumer-side values from exactly
37
40
  # these two named assignments.
@@ -44,8 +47,8 @@ $script:AbandonBlockedReasonCode = 'PARALLEL_ABANDON_BLOCKED'
44
47
  function Get-ParallelAbandonGateToolInput {
45
48
  <#
46
49
  .SYNOPSIS
47
- Read the raw JSON tool payload supplied by Claude Code. Tests mock this
48
- function (read seam).
50
+ Read the raw JSON hook payload through the shared reader (stdin first, then
51
+ the two environment fallbacks). Tests mock this function (read seam).
49
52
  .OUTPUTS
50
53
  System.String or $null
51
54
  #>
@@ -53,7 +56,7 @@ function Get-ParallelAbandonGateToolInput {
53
56
  [OutputType([string])]
54
57
  param()
55
58
 
56
- return $env:CLAUDE_TOOL_INPUT
59
+ return Read-ClaudeHookRawPayload
57
60
  }
58
61
 
59
62
  function Get-ParallelAbandonNormalizedCommand {
@@ -201,7 +204,7 @@ function Get-ParallelAbandonGateBlockReason {
201
204
  function Invoke-ParallelAbandonGateDecision {
202
205
  <#
203
206
  .SYNOPSIS
204
- Parses CLAUDE_TOOL_INPUT and returns an allow-or-block decision.
207
+ Parses the PreToolUse envelope and returns an allow-or-block decision.
205
208
  .PARAMETER ToolInputRaw
206
209
  The raw JSON tool payload supplied by Claude Code.
207
210
  .OUTPUTS
@@ -218,17 +221,16 @@ function Invoke-ParallelAbandonGateDecision {
218
221
  # An absent payload carries no command to gate, so there is nothing in scope. This is
219
222
  # allow rather than deny because the gate constrains one specific destructive command
220
223
  # shape and must not become a blanket Bash block.
221
- if (-not $ToolInputRaw) {
222
- return Get-ParallelAbandonGateAllowDecision
224
+ $payload = Resolve-ClaudeHookToolInput -Raw $ToolInputRaw
225
+ if (-not $payload.IsValid) {
226
+ return Get-ParallelAbandonGateBlockDecision -Reason (
227
+ $script:AbandonBlockedReasonCode + ': payload anomaly - ' +
228
+ (Get-ClaudeHookPayloadAnomalyReason -Anomaly $payload.Anomaly) +
229
+ '. The gate fails closed on an envelope it cannot read.')
223
230
  }
224
231
 
225
- try {
226
- $toolInput = $ToolInputRaw | ConvertFrom-Json -ErrorAction Stop
227
- } catch {
228
- throw "enforce-parallel-abandon-gate hook received malformed JSON in CLAUDE_TOOL_INPUT: $_"
229
- }
230
-
231
- $normalizedCommand = Get-ParallelAbandonNormalizedCommand -CommandText ([string]$toolInput.command)
232
+ $commandText = Get-ClaudeHookToolInputString -ToolInput $payload.Value -Name 'command'
233
+ $normalizedCommand = Get-ParallelAbandonNormalizedCommand -CommandText $commandText
232
234
  if (-not (Test-ParallelAbandonCommandInScope -NormalizedCommand $normalizedCommand)) {
233
235
  return Get-ParallelAbandonGateAllowDecision
234
236
  }
@@ -247,12 +249,7 @@ if ($MyInvocation.InvocationName -eq '.') {
247
249
  return
248
250
  }
249
251
 
250
- try {
251
- $decision = Invoke-ParallelAbandonGateDecision -ToolInputRaw (Get-ParallelAbandonGateToolInput)
252
- } catch {
253
- Write-Error $_
254
- exit 1
255
- }
252
+ $decision = Invoke-ParallelAbandonGateDecision -ToolInputRaw (Get-ParallelAbandonGateToolInput)
256
253
 
257
254
  $decision | ConvertTo-Json -Compress -Depth 5 | Write-Output
258
255
 
@@ -0,0 +1,278 @@
1
+ <#
2
+ .SYNOPSIS
3
+ Dot-sourced record-resolution and barrier helpers for enforce-parallel-cohort-barrier.ps1.
4
+
5
+ .DESCRIPTION
6
+ Holds the five resolution helpers the Layer 1 parallel cohort barrier uses once a
7
+ delegation has been identified as a parallel-mode orchestrator call:
8
+
9
+ - Find-ParallelCohortBarrierItemRecord: resolves the items[] record for a folder.
10
+ - Find-ParallelCohortBarrierItemByKey: resolves an items[] record by issue_num.
11
+ - Find-ParallelCohortBarrierCohortIndex: projects the current-generation coloring.
12
+ - Get-ParallelCohortBarrierConflictNeighborList: collects conflict-edge neighbours.
13
+ - Test-ParallelCohortBarrierClear: applies the per-edge barrier rule.
14
+
15
+ They are split out of the parent hook, .claude/hooks/enforce-parallel-cohort-barrier.ps1,
16
+ because that file stood at 499 lines -- one below the 500-line limit -- with no room
17
+ for the shared payload-reader migration (issue #501). The split is a pure move with no
18
+ behaviour change; the precedent is .claude/hooks/enforce-parallel-drift-gate-helpers.ps1.
19
+
20
+ The helpers read the script-scoped configuration the parent hook assigns
21
+ ($script:AllowedMergeStatuses), so this file is only ever dot-sourced from that hook
22
+ and never invoked on its own.
23
+
24
+ .NOTES
25
+ Compatible with PowerShell 7+. No external module dependencies. Every function is
26
+ pure: no filesystem, subprocess, network, or wall-clock access.
27
+ #>
28
+ [CmdletBinding()]
29
+ param()
30
+
31
+ function Find-ParallelCohortBarrierItemRecord {
32
+ <#
33
+ .SYNOPSIS
34
+ Locate the items[] record whose feature_folder resolves to the target basename.
35
+ .PARAMETER Checkpoint
36
+ Parsed parallel checkpoint, or $null when absent/unreadable.
37
+ .PARAMETER FeatureFolder
38
+ The target feature_folder basename resolved from the prompt.
39
+ .OUTPUTS
40
+ System.Object or $null
41
+ #>
42
+ [CmdletBinding()]
43
+ param(
44
+ [AllowNull()]
45
+ $Checkpoint,
46
+
47
+ [AllowNull()]
48
+ [string] $FeatureFolder
49
+ )
50
+
51
+ if ($null -eq $Checkpoint -or [string]::IsNullOrWhiteSpace($FeatureFolder)) {
52
+ return $null
53
+ }
54
+ $checkpointProps = @($Checkpoint.PSObject.Properties.Name)
55
+ if ($checkpointProps -notcontains 'items') {
56
+ return $null
57
+ }
58
+
59
+ # Scan every recorded item for a feature_folder whose basename equals the target.
60
+ foreach ($item in @($Checkpoint.items)) {
61
+ $itemProps = @($item.PSObject.Properties.Name)
62
+ if ($itemProps -notcontains 'feature_folder') {
63
+ continue
64
+ }
65
+ $itemBasename = Get-ParallelCohortBarrierFolderBasename -FolderPath ([string]$item.feature_folder)
66
+ if ($itemBasename -eq $FeatureFolder) {
67
+ return $item
68
+ }
69
+ }
70
+ return $null
71
+ }
72
+
73
+ function Find-ParallelCohortBarrierItemByKey {
74
+ <#
75
+ .SYNOPSIS
76
+ Locate the items[] record whose issue_num equals the supplied key.
77
+ .PARAMETER Checkpoint
78
+ Parsed parallel checkpoint, or $null when absent/unreadable.
79
+ .PARAMETER IssueKey
80
+ The item primary key (issue_num) rendered as a string.
81
+ .OUTPUTS
82
+ System.Object or $null
83
+ #>
84
+ [CmdletBinding()]
85
+ param(
86
+ [AllowNull()]
87
+ $Checkpoint,
88
+
89
+ [AllowNull()]
90
+ [string] $IssueKey
91
+ )
92
+
93
+ if ($null -eq $Checkpoint -or [string]::IsNullOrWhiteSpace($IssueKey)) {
94
+ return $null
95
+ }
96
+ $checkpointProps = @($Checkpoint.PSObject.Properties.Name)
97
+ if ($checkpointProps -notcontains 'items') {
98
+ return $null
99
+ }
100
+
101
+ foreach ($item in @($Checkpoint.items)) {
102
+ $itemProps = @($item.PSObject.Properties.Name)
103
+ if ($itemProps -notcontains 'issue_num') {
104
+ continue
105
+ }
106
+ if (([string]$item.issue_num) -eq $IssueKey) {
107
+ return $item
108
+ }
109
+ }
110
+ return $null
111
+ }
112
+
113
+ function Find-ParallelCohortBarrierCohortIndex {
114
+ <#
115
+ .SYNOPSIS
116
+ Return the current-generation cohort index that holds the supplied item key.
117
+ .DESCRIPTION
118
+ The cohort projection is restricted to cohorts[] rows whose generation equals the
119
+ top-level recolor_generation, which is the current coloring. Rows from a superseded
120
+ generation are ignored. Returns $null when the checkpoint records no
121
+ recolor_generation, no cohorts, or no current-generation cohort containing the key.
122
+ .PARAMETER Checkpoint
123
+ Parsed parallel checkpoint, or $null when absent/unreadable.
124
+ .PARAMETER IssueKey
125
+ The item primary key (issue_num) rendered as a string.
126
+ .OUTPUTS
127
+ System.Int32 or $null
128
+ #>
129
+ [CmdletBinding()]
130
+ [OutputType([int])]
131
+ param(
132
+ [AllowNull()]
133
+ $Checkpoint,
134
+
135
+ [AllowNull()]
136
+ [string] $IssueKey
137
+ )
138
+
139
+ if ($null -eq $Checkpoint -or [string]::IsNullOrWhiteSpace($IssueKey)) {
140
+ return $null
141
+ }
142
+ $checkpointProps = @($Checkpoint.PSObject.Properties.Name)
143
+ if ($checkpointProps -notcontains 'cohorts' -or $checkpointProps -notcontains 'recolor_generation') {
144
+ return $null
145
+ }
146
+
147
+ $currentGeneration = [string]$Checkpoint.recolor_generation
148
+
149
+ foreach ($cohort in @($Checkpoint.cohorts)) {
150
+ $cohortProps = @($cohort.PSObject.Properties.Name)
151
+ if ($cohortProps -notcontains 'index' -or $cohortProps -notcontains 'generation' -or $cohortProps -notcontains 'item_keys') {
152
+ continue
153
+ }
154
+ if (([string]$cohort.generation) -ne $currentGeneration) {
155
+ continue
156
+ }
157
+ $parsedIndex = 0
158
+ if (-not [int]::TryParse(([string]$cohort.index), [ref] $parsedIndex)) {
159
+ continue
160
+ }
161
+ foreach ($key in @($cohort.item_keys)) {
162
+ if (([string]$key) -eq $IssueKey) {
163
+ return $parsedIndex
164
+ }
165
+ }
166
+ }
167
+ return $null
168
+ }
169
+
170
+ function Get-ParallelCohortBarrierConflictNeighborList {
171
+ <#
172
+ .SYNOPSIS
173
+ Return the item keys that share a conflict edge with the supplied item key.
174
+ .PARAMETER Checkpoint
175
+ Parsed parallel checkpoint, or $null when absent/unreadable.
176
+ .PARAMETER IssueKey
177
+ The item primary key (issue_num) rendered as a string.
178
+ .OUTPUTS
179
+ System.Object[] holding each neighbor item key rendered as a string.
180
+ #>
181
+ [CmdletBinding()]
182
+ [OutputType([object[]])]
183
+ param(
184
+ [AllowNull()]
185
+ $Checkpoint,
186
+
187
+ [AllowNull()]
188
+ [string] $IssueKey
189
+ )
190
+
191
+ $neighborList = @()
192
+ if ($null -eq $Checkpoint -or [string]::IsNullOrWhiteSpace($IssueKey)) {
193
+ return $neighborList
194
+ }
195
+ $checkpointProps = @($Checkpoint.PSObject.Properties.Name)
196
+ if ($checkpointProps -notcontains 'conflict_edges') {
197
+ return $neighborList
198
+ }
199
+
200
+ # Edges are undirected and canonically normalized to a < b, so both endpoints are
201
+ # inspected and the opposite endpoint is returned as the neighbor.
202
+ foreach ($edge in @($Checkpoint.conflict_edges)) {
203
+ $edgeProps = @($edge.PSObject.Properties.Name)
204
+ if ($edgeProps -notcontains 'a' -or $edgeProps -notcontains 'b') {
205
+ continue
206
+ }
207
+ $endpointA = [string]$edge.a
208
+ $endpointB = [string]$edge.b
209
+ if ($endpointA -eq $IssueKey) {
210
+ $neighborList += $endpointB
211
+ } elseif ($endpointB -eq $IssueKey) {
212
+ $neighborList += $endpointA
213
+ }
214
+ }
215
+ return $neighborList
216
+ }
217
+
218
+ function Test-ParallelCohortBarrierClear {
219
+ <#
220
+ .SYNOPSIS
221
+ Decision logic: true only when every conflicting neighbor in a strictly prior
222
+ current-generation cohort has merge_status merged or worktree_removed.
223
+ .PARAMETER Checkpoint
224
+ Parsed parallel checkpoint, or $null when absent/unreadable.
225
+ .PARAMETER ItemRecord
226
+ The target item's own items[] record, or $null when not found.
227
+ .OUTPUTS
228
+ System.Boolean
229
+ #>
230
+ [CmdletBinding()]
231
+ [OutputType([bool])]
232
+ param(
233
+ [AllowNull()]
234
+ $Checkpoint,
235
+
236
+ [AllowNull()]
237
+ $ItemRecord
238
+ )
239
+
240
+ if ($null -eq $Checkpoint -or $null -eq $ItemRecord) {
241
+ return $false
242
+ }
243
+ $itemProps = @($ItemRecord.PSObject.Properties.Name)
244
+ if ($itemProps -notcontains 'issue_num') {
245
+ return $false
246
+ }
247
+
248
+ $targetKey = [string]$ItemRecord.issue_num
249
+ $targetIndex = Find-ParallelCohortBarrierCohortIndex -Checkpoint $Checkpoint -IssueKey $targetKey
250
+ if ($null -eq $targetIndex) {
251
+ # No current-generation cohort assignment: position is unknown, so fail closed.
252
+ return $false
253
+ }
254
+
255
+ foreach ($neighborKey in @(Get-ParallelCohortBarrierConflictNeighborList -Checkpoint $Checkpoint -IssueKey $targetKey)) {
256
+ $neighborIndex = Find-ParallelCohortBarrierCohortIndex -Checkpoint $Checkpoint -IssueKey $neighborKey
257
+ if ($null -eq $neighborIndex) {
258
+ # Not part of the current coloring, so not a strictly prior cohort neighbor.
259
+ continue
260
+ }
261
+ if ($neighborIndex -ge $targetIndex) {
262
+ # Same-cohort and later-cohort contention is not a Layer 1 concern.
263
+ continue
264
+ }
265
+ $neighborRecord = Find-ParallelCohortBarrierItemByKey -Checkpoint $Checkpoint -IssueKey $neighborKey
266
+ if ($null -eq $neighborRecord) {
267
+ return $false
268
+ }
269
+ $neighborProps = @($neighborRecord.PSObject.Properties.Name)
270
+ if ($neighborProps -notcontains 'merge_status') {
271
+ return $false
272
+ }
273
+ if ($script:AllowedMergeStatuses -notcontains ([string]$neighborRecord.merge_status)) {
274
+ return $false
275
+ }
276
+ }
277
+ return $true
278
+ }