@danmoisan/drm-copilot-mcp 1.0.26 → 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 (63) hide show
  1. package/out/mcp-server.js +542 -42
  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 +172 -24
  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 +402 -0
  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/blast-radius/BlastRadius.psm1 +105 -2
  30. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusConfig.psm1 +32 -52
  31. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusExtraction.psm1 +107 -99
  32. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusNormalization.psm1 +295 -0
  33. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusValidation.psm1 +9 -3
  34. package/resources/claude-customizations/.claude/lib/hook-payload/HookPayload.psm1 +494 -0
  35. package/resources/claude-customizations/.claude/lib/mermaid/MermaidGrammar.psm1 +491 -0
  36. package/resources/claude-customizations/.claude/lib/mermaid/MermaidLineScanner.psm1 +488 -0
  37. package/resources/claude-customizations/.claude/lib/mermaid/MermaidMarkdownFences.psm1 +298 -0
  38. package/resources/claude-customizations/.claude/lib/mermaid/MermaidValidation.psm1 +496 -0
  39. package/resources/claude-customizations/.claude/rules/mermaid.md +142 -0
  40. package/resources/claude-customizations/.claude/rules/parallel-orchestration.md +124 -1
  41. package/resources/claude-customizations/.claude/rules/plan-acceptance-gates.md +116 -0
  42. package/resources/claude-customizations/.claude/settings.json +5 -0
  43. package/resources/claude-customizations/.claude/skills/atomic-plan-contract/SKILL.md +15 -0
  44. package/resources/claude-customizations/.claude/skills/evidence-and-timestamp-conventions/SKILL.md +13 -0
  45. package/resources/claude-customizations/.claude/skills/feature-promotion-lifecycle/SKILL.md +6 -0
  46. package/resources/claude-customizations/.claude/skills/mermaid-diagram/SKILL.md +184 -0
  47. package/resources/claude-customizations/.claude/skills/mermaid-diagram/references/c4.md +50 -0
  48. package/resources/claude-customizations/.claude/skills/mermaid-diagram/references/class.md +63 -0
  49. package/resources/claude-customizations/.claude/skills/mermaid-diagram/references/er.md +56 -0
  50. package/resources/claude-customizations/.claude/skills/mermaid-diagram/references/flowchart.md +68 -0
  51. package/resources/claude-customizations/.claude/skills/mermaid-diagram/references/gantt.md +51 -0
  52. package/resources/claude-customizations/.claude/skills/mermaid-diagram/references/other-types.md +82 -0
  53. package/resources/claude-customizations/.claude/skills/mermaid-diagram/references/pie.md +32 -0
  54. package/resources/claude-customizations/.claude/skills/mermaid-diagram/references/sequence.md +63 -0
  55. package/resources/claude-customizations/.claude/skills/mermaid-diagram/references/state.md +49 -0
  56. package/resources/claude-customizations/.claude/skills/parallel-orchestrate/SKILL.md +8 -7
  57. package/resources/claude-customizations/.claude/skills/parallel-plan/SKILL.md +24 -4
  58. package/resources/claude-customizations/config/blast-radius.json +16 -2
  59. package/resources/claude-customizations/pack-manifests/core.json +22 -1
  60. package/resources/codex-and-agents-customizations/.agents/skills/evidence-and-timestamp-conventions/SKILL.md +13 -0
  61. package/resources/codex-and-agents-customizations/.codex/config.toml +1 -1
  62. package/resources/customizations/.github/skills/evidence-and-timestamp-conventions/SKILL.md +13 -0
  63. package/resources/powershell/PoshQC/settings/pester.runsettings.psd1 +31 -1
@@ -4,7 +4,7 @@
4
4
 
5
5
  .DESCRIPTION
6
6
  Invoked by the Claude Code PreToolUse hook on the "Agent" matcher before any Agent
7
- (Task) call runs. Activates only when CLAUDE_TOOL_INPUT.subagent_type == "orchestrator"
7
+ (Task) call runs. Activates only when the envelope's tool_input.subagent_type == "orchestrator"
8
8
  and the serialized prompt contains the parallel kickoff marker "Parallel mode: true".
9
9
 
10
10
  Adapted from enforce-epic-wave-barrier.ps1. The parallel surface has no depends_on
@@ -47,10 +47,16 @@
47
47
  [CmdletBinding()]
48
48
  param()
49
49
 
50
+
51
+ Import-Module (Join-Path $PSScriptRoot '../lib/hook-payload/HookPayload.psm1') -Force
50
52
  $script:ParallelCheckpointPath = 'artifacts/orchestration/parallel-orchestrator-state.json'
51
53
  $script:AllowedMergeStatuses = @('merged', 'worktree_removed')
52
54
  $script:ParallelModeMarker = 'Parallel mode: true'
53
55
 
56
+ # Dot-source the record-resolution and barrier helpers. Guarded so dot-sourcing this
57
+ # hook in tests loads the helpers too (issue #501 headroom split).
58
+ . (Join-Path $PSScriptRoot 'enforce-parallel-cohort-barrier-helpers.ps1')
59
+
54
60
  function Get-ParallelCohortBarrierCheckpointContent {
55
61
  <#
56
62
  .SYNOPSIS
@@ -145,255 +151,6 @@ function Find-ParallelCohortBarrierFeatureFolderFromPrompt {
145
151
  return (Get-ParallelCohortBarrierFolderBasename -FolderPath $candidates[0])
146
152
  }
147
153
 
148
- function Find-ParallelCohortBarrierItemRecord {
149
- <#
150
- .SYNOPSIS
151
- Locate the items[] record whose feature_folder resolves to the target basename.
152
- .PARAMETER Checkpoint
153
- Parsed parallel checkpoint, or $null when absent/unreadable.
154
- .PARAMETER FeatureFolder
155
- The target feature_folder basename resolved from the prompt.
156
- .OUTPUTS
157
- System.Object or $null
158
- #>
159
- [CmdletBinding()]
160
- param(
161
- [AllowNull()]
162
- $Checkpoint,
163
-
164
- [AllowNull()]
165
- [string] $FeatureFolder
166
- )
167
-
168
- if ($null -eq $Checkpoint -or [string]::IsNullOrWhiteSpace($FeatureFolder)) {
169
- return $null
170
- }
171
- $checkpointProps = @($Checkpoint.PSObject.Properties.Name)
172
- if ($checkpointProps -notcontains 'items') {
173
- return $null
174
- }
175
-
176
- # Scan every recorded item for a feature_folder whose basename equals the target.
177
- foreach ($item in @($Checkpoint.items)) {
178
- $itemProps = @($item.PSObject.Properties.Name)
179
- if ($itemProps -notcontains 'feature_folder') {
180
- continue
181
- }
182
- $itemBasename = Get-ParallelCohortBarrierFolderBasename -FolderPath ([string]$item.feature_folder)
183
- if ($itemBasename -eq $FeatureFolder) {
184
- return $item
185
- }
186
- }
187
- return $null
188
- }
189
-
190
- function Find-ParallelCohortBarrierItemByKey {
191
- <#
192
- .SYNOPSIS
193
- Locate the items[] record whose issue_num equals the supplied key.
194
- .PARAMETER Checkpoint
195
- Parsed parallel checkpoint, or $null when absent/unreadable.
196
- .PARAMETER IssueKey
197
- The item primary key (issue_num) rendered as a string.
198
- .OUTPUTS
199
- System.Object or $null
200
- #>
201
- [CmdletBinding()]
202
- param(
203
- [AllowNull()]
204
- $Checkpoint,
205
-
206
- [AllowNull()]
207
- [string] $IssueKey
208
- )
209
-
210
- if ($null -eq $Checkpoint -or [string]::IsNullOrWhiteSpace($IssueKey)) {
211
- return $null
212
- }
213
- $checkpointProps = @($Checkpoint.PSObject.Properties.Name)
214
- if ($checkpointProps -notcontains 'items') {
215
- return $null
216
- }
217
-
218
- foreach ($item in @($Checkpoint.items)) {
219
- $itemProps = @($item.PSObject.Properties.Name)
220
- if ($itemProps -notcontains 'issue_num') {
221
- continue
222
- }
223
- if (([string]$item.issue_num) -eq $IssueKey) {
224
- return $item
225
- }
226
- }
227
- return $null
228
- }
229
-
230
- function Find-ParallelCohortBarrierCohortIndex {
231
- <#
232
- .SYNOPSIS
233
- Return the current-generation cohort index that holds the supplied item key.
234
- .DESCRIPTION
235
- The cohort projection is restricted to cohorts[] rows whose generation equals the
236
- top-level recolor_generation, which is the current coloring. Rows from a superseded
237
- generation are ignored. Returns $null when the checkpoint records no
238
- recolor_generation, no cohorts, or no current-generation cohort containing the key.
239
- .PARAMETER Checkpoint
240
- Parsed parallel checkpoint, or $null when absent/unreadable.
241
- .PARAMETER IssueKey
242
- The item primary key (issue_num) rendered as a string.
243
- .OUTPUTS
244
- System.Int32 or $null
245
- #>
246
- [CmdletBinding()]
247
- [OutputType([int])]
248
- param(
249
- [AllowNull()]
250
- $Checkpoint,
251
-
252
- [AllowNull()]
253
- [string] $IssueKey
254
- )
255
-
256
- if ($null -eq $Checkpoint -or [string]::IsNullOrWhiteSpace($IssueKey)) {
257
- return $null
258
- }
259
- $checkpointProps = @($Checkpoint.PSObject.Properties.Name)
260
- if ($checkpointProps -notcontains 'cohorts' -or $checkpointProps -notcontains 'recolor_generation') {
261
- return $null
262
- }
263
-
264
- $currentGeneration = [string]$Checkpoint.recolor_generation
265
-
266
- foreach ($cohort in @($Checkpoint.cohorts)) {
267
- $cohortProps = @($cohort.PSObject.Properties.Name)
268
- if ($cohortProps -notcontains 'index' -or $cohortProps -notcontains 'generation' -or $cohortProps -notcontains 'item_keys') {
269
- continue
270
- }
271
- if (([string]$cohort.generation) -ne $currentGeneration) {
272
- continue
273
- }
274
- $parsedIndex = 0
275
- if (-not [int]::TryParse(([string]$cohort.index), [ref] $parsedIndex)) {
276
- continue
277
- }
278
- foreach ($key in @($cohort.item_keys)) {
279
- if (([string]$key) -eq $IssueKey) {
280
- return $parsedIndex
281
- }
282
- }
283
- }
284
- return $null
285
- }
286
-
287
- function Get-ParallelCohortBarrierConflictNeighborList {
288
- <#
289
- .SYNOPSIS
290
- Return the item keys that share a conflict edge with the supplied item key.
291
- .PARAMETER Checkpoint
292
- Parsed parallel checkpoint, or $null when absent/unreadable.
293
- .PARAMETER IssueKey
294
- The item primary key (issue_num) rendered as a string.
295
- .OUTPUTS
296
- System.Object[] holding each neighbor item key rendered as a string.
297
- #>
298
- [CmdletBinding()]
299
- [OutputType([object[]])]
300
- param(
301
- [AllowNull()]
302
- $Checkpoint,
303
-
304
- [AllowNull()]
305
- [string] $IssueKey
306
- )
307
-
308
- $neighborList = @()
309
- if ($null -eq $Checkpoint -or [string]::IsNullOrWhiteSpace($IssueKey)) {
310
- return $neighborList
311
- }
312
- $checkpointProps = @($Checkpoint.PSObject.Properties.Name)
313
- if ($checkpointProps -notcontains 'conflict_edges') {
314
- return $neighborList
315
- }
316
-
317
- # Edges are undirected and canonically normalized to a < b, so both endpoints are
318
- # inspected and the opposite endpoint is returned as the neighbor.
319
- foreach ($edge in @($Checkpoint.conflict_edges)) {
320
- $edgeProps = @($edge.PSObject.Properties.Name)
321
- if ($edgeProps -notcontains 'a' -or $edgeProps -notcontains 'b') {
322
- continue
323
- }
324
- $endpointA = [string]$edge.a
325
- $endpointB = [string]$edge.b
326
- if ($endpointA -eq $IssueKey) {
327
- $neighborList += $endpointB
328
- } elseif ($endpointB -eq $IssueKey) {
329
- $neighborList += $endpointA
330
- }
331
- }
332
- return $neighborList
333
- }
334
-
335
- function Test-ParallelCohortBarrierClear {
336
- <#
337
- .SYNOPSIS
338
- Decision logic: true only when every conflicting neighbor in a strictly prior
339
- current-generation cohort has merge_status merged or worktree_removed.
340
- .PARAMETER Checkpoint
341
- Parsed parallel checkpoint, or $null when absent/unreadable.
342
- .PARAMETER ItemRecord
343
- The target item's own items[] record, or $null when not found.
344
- .OUTPUTS
345
- System.Boolean
346
- #>
347
- [CmdletBinding()]
348
- [OutputType([bool])]
349
- param(
350
- [AllowNull()]
351
- $Checkpoint,
352
-
353
- [AllowNull()]
354
- $ItemRecord
355
- )
356
-
357
- if ($null -eq $Checkpoint -or $null -eq $ItemRecord) {
358
- return $false
359
- }
360
- $itemProps = @($ItemRecord.PSObject.Properties.Name)
361
- if ($itemProps -notcontains 'issue_num') {
362
- return $false
363
- }
364
-
365
- $targetKey = [string]$ItemRecord.issue_num
366
- $targetIndex = Find-ParallelCohortBarrierCohortIndex -Checkpoint $Checkpoint -IssueKey $targetKey
367
- if ($null -eq $targetIndex) {
368
- # No current-generation cohort assignment: position is unknown, so fail closed.
369
- return $false
370
- }
371
-
372
- foreach ($neighborKey in @(Get-ParallelCohortBarrierConflictNeighborList -Checkpoint $Checkpoint -IssueKey $targetKey)) {
373
- $neighborIndex = Find-ParallelCohortBarrierCohortIndex -Checkpoint $Checkpoint -IssueKey $neighborKey
374
- if ($null -eq $neighborIndex) {
375
- # Not part of the current coloring, so not a strictly prior cohort neighbor.
376
- continue
377
- }
378
- if ($neighborIndex -ge $targetIndex) {
379
- # Same-cohort and later-cohort contention is not a Layer 1 concern.
380
- continue
381
- }
382
- $neighborRecord = Find-ParallelCohortBarrierItemByKey -Checkpoint $Checkpoint -IssueKey $neighborKey
383
- if ($null -eq $neighborRecord) {
384
- return $false
385
- }
386
- $neighborProps = @($neighborRecord.PSObject.Properties.Name)
387
- if ($neighborProps -notcontains 'merge_status') {
388
- return $false
389
- }
390
- if ($script:AllowedMergeStatuses -notcontains ([string]$neighborRecord.merge_status)) {
391
- return $false
392
- }
393
- }
394
- return $true
395
- }
396
-
397
154
  function Get-ParallelCohortBarrierAllowDecision {
398
155
  [CmdletBinding()]
399
156
  [OutputType([System.Collections.Specialized.OrderedDictionary])]
@@ -427,7 +184,7 @@ function Get-ParallelCohortBarrierBlockDecision {
427
184
  function Invoke-ParallelCohortBarrierDecision {
428
185
  <#
429
186
  .SYNOPSIS
430
- Parses CLAUDE_TOOL_INPUT and returns an allow-or-block decision.
187
+ Parses the PreToolUse envelope and returns an allow-or-block decision.
431
188
  .PARAMETER ToolInputRaw
432
189
  The raw JSON tool payload supplied by Claude Code.
433
190
  .OUTPUTS
@@ -439,22 +196,20 @@ function Invoke-ParallelCohortBarrierDecision {
439
196
  [string] $ToolInputRaw
440
197
  )
441
198
 
442
- if (-not $ToolInputRaw) {
443
- return Get-ParallelCohortBarrierAllowDecision
444
- }
445
-
446
- try {
447
- $toolInput = $ToolInputRaw | ConvertFrom-Json -ErrorAction Stop
448
- } catch {
449
- throw "enforce-parallel-cohort-barrier hook received malformed JSON in CLAUDE_TOOL_INPUT: $_"
199
+ $envelope = Resolve-ClaudeHookToolInput -Raw $ToolInputRaw
200
+ if (-not $envelope.IsValid) {
201
+ return Get-ParallelCohortBarrierBlockDecision -Reason (
202
+ 'PARALLEL_COHORT_BARRIER_BLOCKED: payload anomaly - ' +
203
+ (Get-ClaudeHookPayloadAnomalyReason -Anomaly $envelope.Anomaly) +
204
+ '. The gate fails closed on an envelope it cannot read.')
450
205
  }
451
206
 
452
- $subagent = $toolInput.subagent_type
207
+ $subagent = Get-ClaudeHookToolInputString -ToolInput $envelope.Value -Name 'subagent_type'
453
208
  if (-not $subagent -or $subagent -ne 'orchestrator') {
454
209
  return Get-ParallelCohortBarrierAllowDecision
455
210
  }
456
211
 
457
- $prompt = [string]$toolInput.prompt
212
+ $prompt = Get-ClaudeHookToolInputString -ToolInput $envelope.Value -Name 'prompt'
458
213
  if (-not $prompt -or $prompt -notlike "*$script:ParallelModeMarker*") {
459
214
  return Get-ParallelCohortBarrierAllowDecision
460
215
  }
@@ -482,18 +237,47 @@ function Invoke-ParallelCohortBarrierDecision {
482
237
  return Get-ParallelCohortBarrierBlockDecision -Reason "PARALLEL_COHORT_BARRIER_BLOCKED: '$featureFolder' cannot start until every conflicting item in a strictly prior current-generation cohort is durably confirmed merged or worktree_removed in the parallel checkpoint. The checkpoint was unreadable, the items[] record was not found, the item has no current-generation cohort assignment, or a conflicting prior-cohort item is not yet safe (ci_green does not satisfy the barrier)."
483
238
  }
484
239
 
240
+ function Invoke-ParallelCohortBarrierEntryPoint {
241
+ <#
242
+ .SYNOPSIS
243
+ Runs the hook decision and returns the process exit code.
244
+ .DESCRIPTION
245
+ Acquires the payload through the shared reader unless the caller supplies
246
+ one, emits the compact decision JSON, and returns 0. It never returns 1:
247
+ exit 1 is non-blocking for PreToolUse, so every anomaly is already a deny
248
+ decision by the time control reaches here. The function does not call exit;
249
+ the thin tail converts the returned code into a process exit.
250
+ .PARAMETER ToolInputRaw
251
+ Optional pre-acquired payload text. When omitted the ReadPayload seam runs.
252
+ .PARAMETER ReadPayload
253
+ Seam for payload acquisition, so tests can drive the empty-on-all-transports
254
+ case without touching a console.
255
+ .OUTPUTS
256
+ System.Int32
257
+ #>
258
+ [CmdletBinding()]
259
+ [OutputType([int])]
260
+ param(
261
+ [AllowNull()]
262
+ [AllowEmptyString()]
263
+ [string] $ToolInputRaw,
264
+
265
+ [scriptblock] $ReadPayload = { Read-ClaudeHookRawPayload }
266
+ )
267
+
268
+ if (-not $PSBoundParameters.ContainsKey('ToolInputRaw')) {
269
+ $ToolInputRaw = [string](& $ReadPayload)
270
+ }
271
+
272
+ $decision = Invoke-ParallelCohortBarrierDecision -ToolInputRaw $ToolInputRaw
273
+ $decision | ConvertTo-Json -Compress -Depth 5 | Write-Output
274
+
275
+ return 0
276
+ }
277
+
485
278
  # Guard allows dot-sourcing in tests without executing the entrypoint.
486
279
  if ($MyInvocation.InvocationName -eq '.') {
487
280
  return
488
281
  }
489
282
 
490
- try {
491
- $decision = Invoke-ParallelCohortBarrierDecision -ToolInputRaw $env:CLAUDE_TOOL_INPUT
492
- } catch {
493
- Write-Error $_
494
- exit 1
495
- }
496
-
497
- $decision | ConvertTo-Json -Compress -Depth 5 | Write-Output
498
-
499
- exit 0
283
+ exit (Invoke-ParallelCohortBarrierEntryPoint)
@@ -4,7 +4,7 @@
4
4
 
5
5
  .DESCRIPTION
6
6
  Invoked by the Claude Code PreToolUse hook on the "Agent" matcher. Activates only when
7
- CLAUDE_TOOL_INPUT.subagent_type == "feature-review" and the prompt carries the
7
+ the envelope's nested tool_input.subagent_type == "feature-review" and the prompt carries the
8
8
  parallel-mode kickoff marker defined in the "Parallel-Mode Kickoff Parameter" section
9
9
  of .claude/skills/parallel-orchestrate/SKILL.md, matched byte-for-byte (ordinal).
10
10
 
@@ -61,6 +61,8 @@
61
61
  [CmdletBinding()]
62
62
  param()
63
63
 
64
+
65
+ Import-Module (Join-Path $PSScriptRoot '../lib/hook-payload/HookPayload.psm1') -Force
64
66
  # Dot-source the shape-and-derivation helpers. Guarded so a missing file produces a clear error
65
67
  # and so dot-sourcing this hook in tests loads the helpers too.
66
68
  $script:ParallelDriftGateHelpersPath = Join-Path $PSScriptRoot 'enforce-parallel-drift-gate-helpers.ps1'
@@ -270,28 +272,32 @@ function Get-ParallelDriftGateBlockDecision {
270
272
  function Invoke-ParallelDriftGateDecision {
271
273
  <#
272
274
  .SYNOPSIS
273
- Parse the raw CLAUDE_TOOL_INPUT JSON payload and return an allow-or-block decision.
275
+ Parse the PreToolUse envelope and return an allow-or-block decision.
274
276
  #>
275
277
  [CmdletBinding()]
276
278
  [OutputType([System.Collections.Specialized.OrderedDictionary])]
277
- param([string] $ToolInputRaw)
279
+ param(
280
+ [AllowNull()]
281
+ [AllowEmptyString()]
282
+ [string] $ToolInputRaw
283
+ )
278
284
 
279
- if (-not $ToolInputRaw) {
280
- return Get-ParallelDriftGateAllowDecision
281
- }
282
- try {
283
- $toolInput = $ToolInputRaw | ConvertFrom-Json -ErrorAction Stop
284
- } catch {
285
- throw "enforce-parallel-drift-gate hook received malformed JSON in CLAUDE_TOOL_INPUT: $_"
285
+ $envelope = Resolve-ClaudeHookToolInput -Raw $ToolInputRaw
286
+ if (-not $envelope.IsValid) {
287
+ return Get-ParallelDriftGateBlockDecision -Reason (
288
+ 'PARALLEL_DRIFT_GATE_BLOCKED: payload anomaly - ' +
289
+ (Get-ClaudeHookPayloadAnomalyReason -Anomaly $envelope.Anomaly) +
290
+ '. The gate fails closed on an envelope it cannot read.')
286
291
  }
287
292
 
288
293
  # Two cheap disqualifiers first: this gate governs only feature-review delegations, and
289
294
  # only under the parallel-mode marker, matched byte-for-byte with an ordinal Contains
290
295
  # rather than a wildcard or a culture-sensitive comparison.
291
- if (([string]$toolInput.subagent_type) -cne $script:ReviewSubagentType) {
296
+ $subagent = Get-ClaudeHookToolInputString -ToolInput $envelope.Value -Name 'subagent_type'
297
+ if ($subagent -cne $script:ReviewSubagentType) {
292
298
  return Get-ParallelDriftGateAllowDecision
293
299
  }
294
- $prompt = [string]$toolInput.prompt
300
+ $prompt = Get-ClaudeHookToolInputString -ToolInput $envelope.Value -Name 'prompt'
295
301
  if (-not $prompt -or -not $prompt.Contains($script:ParallelModeMarker, [System.StringComparison]::Ordinal)) {
296
302
  return Get-ParallelDriftGateAllowDecision
297
303
  }
@@ -342,18 +348,47 @@ function Invoke-ParallelDriftGateDecision {
342
348
  return Get-ParallelDriftGateBlockDecision -Reason "PARALLEL_DRIFT_GATE_BLOCKED: item $itemKey ('$featureFolder') has an unresolved radius drift event and no $script:FindingFilePrefix<timestamp>$script:FindingFileSuffix finding dated at or after that event recorded in its feature folder. The synthetic Blocking finding for the current event must be written before review proceeds, or the drift event log was unreadable."
343
349
  }
344
350
 
351
+ function Invoke-ParallelDriftGateEntryPoint {
352
+ <#
353
+ .SYNOPSIS
354
+ Runs the hook decision and returns the process exit code.
355
+ .DESCRIPTION
356
+ Acquires the payload through the shared reader unless the caller supplies
357
+ one, emits the compact decision JSON, and returns 0. It never returns 1:
358
+ exit 1 is non-blocking for PreToolUse, so every anomaly is already a deny
359
+ decision by the time control reaches here. The function does not call exit;
360
+ the thin tail converts the returned code into a process exit.
361
+ .PARAMETER ToolInputRaw
362
+ Optional pre-acquired payload text. When omitted the ReadPayload seam runs.
363
+ .PARAMETER ReadPayload
364
+ Seam for payload acquisition, so tests can drive the empty-on-all-transports
365
+ case without touching a console.
366
+ .OUTPUTS
367
+ System.Int32
368
+ #>
369
+ [CmdletBinding()]
370
+ [OutputType([int])]
371
+ param(
372
+ [AllowNull()]
373
+ [AllowEmptyString()]
374
+ [string] $ToolInputRaw,
375
+
376
+ [scriptblock] $ReadPayload = { Read-ClaudeHookRawPayload }
377
+ )
378
+
379
+ if (-not $PSBoundParameters.ContainsKey('ToolInputRaw')) {
380
+ $ToolInputRaw = [string](& $ReadPayload)
381
+ }
382
+
383
+ $decision = Invoke-ParallelDriftGateDecision -ToolInputRaw $ToolInputRaw
384
+ $decision | ConvertTo-Json -Compress -Depth 5 | Write-Output
385
+
386
+ return 0
387
+ }
388
+
345
389
  # Guard allows dot-sourcing in tests without executing the entrypoint.
346
390
  if ($MyInvocation.InvocationName -eq '.') {
347
391
  return
348
392
  }
349
393
 
350
- try {
351
- $decision = Invoke-ParallelDriftGateDecision -ToolInputRaw $env:CLAUDE_TOOL_INPUT
352
- } catch {
353
- Write-Error $_
354
- exit 1
355
- }
356
-
357
- $decision | ConvertTo-Json -Compress -Depth 5 | Write-Output
358
-
359
- exit 0
394
+ exit (Invoke-ParallelDriftGateEntryPoint)
@@ -4,7 +4,7 @@
4
4
 
5
5
  .DESCRIPTION
6
6
  Invoked by the Claude Code PreToolUse hook on the "Bash" matcher before any Bash
7
- command runs. Regex-matches git worktree remove against CLAUDE_TOOL_INPUT.command,
7
+ command runs. Regex-matches git worktree remove against the envelope's tool_input.command,
8
8
  extracts the target worktree path argument, reads
9
9
  artifacts/orchestration/parallel-orchestrator-state.json, and finds the items[] record
10
10
  whose worktree_path matches. Allows removal only when that record's merge_status is
@@ -28,6 +28,8 @@
28
28
  [CmdletBinding()]
29
29
  param()
30
30
 
31
+
32
+ Import-Module (Join-Path $PSScriptRoot '../lib/hook-payload/HookPayload.psm1') -Force
31
33
  $script:ParallelCheckpointPath = 'artifacts/orchestration/parallel-orchestrator-state.json'
32
34
  $script:AllowedMergeStatuses = @('merged', 'worktree_removed')
33
35
 
@@ -176,7 +178,7 @@ function Get-ParallelWorktreeGateBlockDecision {
176
178
  function Invoke-ParallelWorktreeRemovalGateDecision {
177
179
  <#
178
180
  .SYNOPSIS
179
- Parses CLAUDE_TOOL_INPUT and returns an allow-or-block decision.
181
+ Parses the PreToolUse envelope and returns an allow-or-block decision.
180
182
  .PARAMETER ToolInputRaw
181
183
  The raw JSON tool payload supplied by Claude Code.
182
184
  .OUTPUTS
@@ -188,17 +190,15 @@ function Invoke-ParallelWorktreeRemovalGateDecision {
188
190
  [string] $ToolInputRaw
189
191
  )
190
192
 
191
- if (-not $ToolInputRaw) {
192
- return Get-ParallelWorktreeGateAllowDecision
193
- }
194
-
195
- try {
196
- $toolInput = $ToolInputRaw | ConvertFrom-Json -ErrorAction Stop
197
- } catch {
198
- throw "enforce-parallel-worktree-removal-gate hook received malformed JSON in CLAUDE_TOOL_INPUT: $_"
193
+ $payload = Resolve-ClaudeHookToolInput -Raw $ToolInputRaw
194
+ if (-not $payload.IsValid) {
195
+ return Get-ParallelWorktreeGateBlockDecision -Reason (
196
+ 'PARALLEL_WORKTREE_REMOVAL_BLOCKED: payload anomaly - ' +
197
+ (Get-ClaudeHookPayloadAnomalyReason -Anomaly $payload.Anomaly) +
198
+ '. The gate fails closed on an envelope it cannot read.')
199
199
  }
200
200
 
201
- $commandText = $toolInput.command
201
+ $commandText = Get-ClaudeHookToolInputString -ToolInput $payload.Value -Name 'command'
202
202
  if (-not $commandText) {
203
203
  return Get-ParallelWorktreeGateAllowDecision
204
204
  }
@@ -227,18 +227,55 @@ function Invoke-ParallelWorktreeRemovalGateDecision {
227
227
  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
228
  }
229
229
 
230
+ function Invoke-ParallelWorktreeRemovalGateEntryPoint {
231
+ <#
232
+ .SYNOPSIS
233
+ Runs the hook decision and returns the process exit code.
234
+ .DESCRIPTION
235
+ Acquires the payload through the shared reader unless the caller supplies
236
+ one, emits the compact decision JSON, and returns 0. It never returns 1:
237
+ exit 1 is non-blocking for PreToolUse, so every anomaly is already a deny
238
+ decision by the time control reaches here. The function does not call exit;
239
+ the thin tail converts the returned code into a process exit.
240
+ .PARAMETER ToolInputRaw
241
+ Optional pre-acquired payload text. When omitted the ReadPayload seam runs.
242
+ .PARAMETER ReadPayload
243
+ Seam for payload acquisition, so tests can drive the empty-on-all-transports
244
+ case without touching a console.
245
+ .OUTPUTS
246
+ System.Int32
247
+ #>
248
+ [CmdletBinding()]
249
+ [OutputType([int])]
250
+ param(
251
+ [AllowNull()]
252
+ [AllowEmptyString()]
253
+ [string] $ToolInputRaw,
254
+
255
+ [scriptblock] $ReadPayload = { Read-ClaudeHookRawPayload }
256
+ )
257
+
258
+ if (-not $PSBoundParameters.ContainsKey('ToolInputRaw')) {
259
+ $ToolInputRaw = [string](& $ReadPayload)
260
+ }
261
+
262
+ $decision = Invoke-ParallelWorktreeRemovalGateDecision -ToolInputRaw $ToolInputRaw
263
+ $decision | ConvertTo-Json -Compress -Depth 5 | Write-Output
264
+
265
+ return 0
266
+ }
267
+
230
268
  # Guard allows dot-sourcing in tests without executing the entrypoint.
231
269
  if ($MyInvocation.InvocationName -eq '.') {
232
270
  return
233
271
  }
234
272
 
235
- try {
236
- $decision = Invoke-ParallelWorktreeRemovalGateDecision -ToolInputRaw $env:CLAUDE_TOOL_INPUT
237
- } catch {
238
- Write-Error $_
239
- exit 1
273
+ # The entry point returns its [int] exit code as the last pipeline element and the
274
+ # decision JSON before it. `exit (<call>)` would capture BOTH into the exit
275
+ # expression and emit nothing, so the decision is written explicitly here first.
276
+ $entryPointResult = @(Invoke-ParallelWorktreeRemovalGateEntryPoint)
277
+ if ($entryPointResult.Count -gt 1) {
278
+ $entryPointResult[0..($entryPointResult.Count - 2)] | Write-Output
240
279
  }
241
280
 
242
- $decision | ConvertTo-Json -Compress -Depth 5 | Write-Output
243
-
244
- exit 0
281
+ exit ([int]$entryPointResult[-1])