@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
@@ -5,7 +5,7 @@
5
5
  .DESCRIPTION
6
6
  Invoked by the Claude Code PreToolUse hook on the "Bash" matcher before any Bash
7
7
  command runs. Regex-matches gh pr merge with a --merge flag against
8
- CLAUDE_TOOL_INPUT.command and, when matched, allows the merge only when one of three
8
+ the envelope's tool_input.command and, when matched, allows the merge only when one of three
9
9
  checkpoint-only conditions holds:
10
10
 
11
11
  1. Child-feature path: artifacts/orchestration/orchestrator-state.json exists,
@@ -39,6 +39,8 @@
39
39
  [CmdletBinding()]
40
40
  param()
41
41
 
42
+ Import-Module (Join-Path $PSScriptRoot '../lib/hook-payload/HookPayload.psm1') -Force
43
+
42
44
  $script:ChildCheckpointPath = 'artifacts/orchestration/orchestrator-state.json'
43
45
  $script:EpicCheckpointPath = 'artifacts/orchestration/epic-orchestrator-state.json'
44
46
  $script:ParallelCheckpointPath = 'artifacts/orchestration/parallel-orchestrator-state.json'
@@ -338,29 +340,34 @@ function Get-EpicMergeGateBlockDecision {
338
340
  function Invoke-EpicMergeGateDecision {
339
341
  <#
340
342
  .SYNOPSIS
341
- Parses CLAUDE_TOOL_INPUT and returns an allow-or-block decision.
343
+ Parses the PreToolUse envelope and returns an allow-or-block decision.
344
+ .DESCRIPTION
345
+ Envelope-level anomalies (empty payload, unparseable JSON, missing or
346
+ malformed tool_input) fail closed as a deny; the legacy flat root shape is
347
+ one such anomaly. Property-level absence of command inside a well-formed
348
+ tool_input remains an allow, because that is this gate's scope filter.
342
349
  .PARAMETER ToolInputRaw
343
- The raw JSON tool payload supplied by Claude Code.
350
+ The raw JSON hook payload acquired by Read-ClaudeHookRawPayload.
344
351
  .OUTPUTS
345
352
  System.Collections.Specialized.OrderedDictionary
346
353
  #>
347
354
  [CmdletBinding()]
348
355
  [OutputType([System.Collections.Specialized.OrderedDictionary])]
349
356
  param(
357
+ [AllowNull()]
358
+ [AllowEmptyString()]
350
359
  [string] $ToolInputRaw
351
360
  )
352
361
 
353
- if (-not $ToolInputRaw) {
354
- return Get-EpicMergeGateAllowDecision
355
- }
356
-
357
- try {
358
- $toolInput = $ToolInputRaw | ConvertFrom-Json -ErrorAction Stop
359
- } catch {
360
- throw "enforce-epic-merge-gate hook received malformed JSON in CLAUDE_TOOL_INPUT: $_"
362
+ $payload = Resolve-ClaudeHookToolInput -Raw $ToolInputRaw
363
+ if (-not $payload.IsValid) {
364
+ return Get-EpicMergeGateBlockDecision -Reason (
365
+ 'EPIC_MERGE_GATE_BLOCKED: payload anomaly - ' +
366
+ (Get-ClaudeHookPayloadAnomalyReason -Anomaly $payload.Anomaly) +
367
+ '. The gate fails closed on an envelope it cannot read.')
361
368
  }
362
369
 
363
- $commandText = $toolInput.command
370
+ $commandText = Get-ClaudeHookToolInputString -ToolInput $payload.Value -Name 'command'
364
371
  if (-not $commandText) {
365
372
  return Get-EpicMergeGateAllowDecision
366
373
  }
@@ -391,18 +398,55 @@ function Invoke-EpicMergeGateDecision {
391
398
  return Get-EpicMergeGateBlockDecision -Reason 'EPIC_MERGE_GATE_BLOCKED: gh pr merge --merge requires either a per-feature checkpoint with epic_mode == true and step9_status == "passed", an epic checkpoint with epic_merge_pr.ci_gate.conclusion == "success" and a matching pr_number, or a parallel-orchestrator checkpoint with route_id == "parallel" whose target item (matched by pr_number) has merge_status == "ci_green". No checkpoint satisfied this gate.'
392
399
  }
393
400
 
401
+ function Invoke-EpicMergeGateEntryPoint {
402
+ <#
403
+ .SYNOPSIS
404
+ Runs the merge-gate decision and returns the process exit code.
405
+ .DESCRIPTION
406
+ Acquires the payload through the shared reader unless the caller supplies
407
+ one, emits the compact decision JSON, and returns 0. It never returns 1:
408
+ exit 1 is non-blocking for PreToolUse, so every anomaly is already a deny
409
+ decision by the time control reaches here. The function does not call exit;
410
+ the thin tail converts the returned code into a process exit.
411
+ .PARAMETER ToolInputRaw
412
+ Optional pre-acquired payload text. When omitted the ReadPayload seam runs.
413
+ .PARAMETER ReadPayload
414
+ Seam for payload acquisition, so tests can drive the empty-on-all-transports
415
+ case without touching a console.
416
+ .OUTPUTS
417
+ System.Int32
418
+ #>
419
+ [CmdletBinding()]
420
+ [OutputType([int])]
421
+ param(
422
+ [AllowNull()]
423
+ [AllowEmptyString()]
424
+ [string] $ToolInputRaw,
425
+
426
+ [scriptblock] $ReadPayload = { Read-ClaudeHookRawPayload }
427
+ )
428
+
429
+ if (-not $PSBoundParameters.ContainsKey('ToolInputRaw')) {
430
+ $ToolInputRaw = [string](& $ReadPayload)
431
+ }
432
+
433
+ $decision = Invoke-EpicMergeGateDecision -ToolInputRaw $ToolInputRaw
434
+ $decision | ConvertTo-Json -Compress -Depth 5 | Write-Output
435
+
436
+ return 0
437
+ }
438
+
394
439
  # Guard allows dot-sourcing in tests without executing the entrypoint.
395
440
  if ($MyInvocation.InvocationName -eq '.') {
396
441
  return
397
442
  }
398
443
 
399
- try {
400
- $decision = Invoke-EpicMergeGateDecision -ToolInputRaw $env:CLAUDE_TOOL_INPUT
401
- } catch {
402
- Write-Error $_
403
- exit 1
444
+ # The entry point returns its [int] exit code as the last pipeline element and the
445
+ # decision JSON before it. `exit (<call>)` would capture BOTH into the exit
446
+ # expression and emit nothing, so the decision is written explicitly here first.
447
+ $entryPointResult = @(Invoke-EpicMergeGateEntryPoint)
448
+ if ($entryPointResult.Count -gt 1) {
449
+ $entryPointResult[0..($entryPointResult.Count - 2)] | Write-Output
404
450
  }
405
451
 
406
- $decision | ConvertTo-Json -Compress -Depth 5 | Write-Output
407
-
408
- exit 0
452
+ exit ([int]$entryPointResult[-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 nested tool_input.subagent_type == "orchestrator"
8
8
  and the serialized prompt contains the epic-mode kickoff marker "Epic mode: true".
9
9
 
10
10
  Resolution and decision procedure:
@@ -33,6 +33,8 @@
33
33
  [CmdletBinding()]
34
34
  param()
35
35
 
36
+
37
+ Import-Module (Join-Path $PSScriptRoot '../lib/hook-payload/HookPayload.psm1') -Force
36
38
  $script:EpicCheckpointPath = 'artifacts/orchestration/epic-orchestrator-state.json'
37
39
  $script:AllowedMergeStatuses = @('merged', 'worktree_removed')
38
40
  $script:EpicModeMarker = 'Epic mode: true'
@@ -232,7 +234,7 @@ function Get-EpicWaveBarrierBlockDecision {
232
234
  function Invoke-EpicWaveBarrierDecision {
233
235
  <#
234
236
  .SYNOPSIS
235
- Parses CLAUDE_TOOL_INPUT and returns an allow-or-block decision.
237
+ Parses the envelope's nested tool_input and returns an allow-or-block decision.
236
238
  .PARAMETER ToolInputRaw
237
239
  The raw JSON tool payload supplied by Claude Code.
238
240
  .OUTPUTS
@@ -244,22 +246,20 @@ function Invoke-EpicWaveBarrierDecision {
244
246
  [string] $ToolInputRaw
245
247
  )
246
248
 
247
- if (-not $ToolInputRaw) {
248
- return Get-EpicWaveBarrierAllowDecision
249
- }
250
-
251
- try {
252
- $toolInput = $ToolInputRaw | ConvertFrom-Json -ErrorAction Stop
253
- } catch {
254
- throw "enforce-epic-wave-barrier hook received malformed JSON in CLAUDE_TOOL_INPUT: $_"
249
+ $envelope = Resolve-ClaudeHookToolInput -Raw $ToolInputRaw
250
+ if (-not $envelope.IsValid) {
251
+ return Get-EpicWaveBarrierBlockDecision -Reason (
252
+ 'EPIC_WAVE_BARRIER_BLOCKED: payload anomaly - ' +
253
+ (Get-ClaudeHookPayloadAnomalyReason -Anomaly $envelope.Anomaly) +
254
+ '. The gate fails closed on an envelope it cannot read.')
255
255
  }
256
256
 
257
- $subagent = $toolInput.subagent_type
257
+ $subagent = Get-ClaudeHookToolInputString -ToolInput $envelope.Value -Name 'subagent_type'
258
258
  if (-not $subagent -or $subagent -ne 'orchestrator') {
259
259
  return Get-EpicWaveBarrierAllowDecision
260
260
  }
261
261
 
262
- $prompt = [string]$toolInput.prompt
262
+ $prompt = Get-ClaudeHookToolInputString -ToolInput $envelope.Value -Name 'prompt'
263
263
  if (-not $prompt -or $prompt -notlike "*$script:EpicModeMarker*") {
264
264
  return Get-EpicWaveBarrierAllowDecision
265
265
  }
@@ -287,18 +287,47 @@ function Invoke-EpicWaveBarrierDecision {
287
287
  return Get-EpicWaveBarrierBlockDecision -Reason "EPIC_WAVE_BARRIER_BLOCKED: '$featureFolder' cannot start until every dependency in its depends_on list is durably confirmed merged or worktree_removed in the epic checkpoint. The checkpoint was unreadable, the feature record was not found, or a dependency is not yet safe."
288
288
  }
289
289
 
290
+ function Invoke-EpicWaveBarrierEntryPoint {
291
+ <#
292
+ .SYNOPSIS
293
+ Runs the hook decision and returns the process exit code.
294
+ .DESCRIPTION
295
+ Acquires the payload through the shared reader unless the caller supplies
296
+ one, emits the compact decision JSON, and returns 0. It never returns 1:
297
+ exit 1 is non-blocking for PreToolUse, so every anomaly is already a deny
298
+ decision by the time control reaches here. The function does not call exit;
299
+ the thin tail converts the returned code into a process exit.
300
+ .PARAMETER ToolInputRaw
301
+ Optional pre-acquired payload text. When omitted the ReadPayload seam runs.
302
+ .PARAMETER ReadPayload
303
+ Seam for payload acquisition, so tests can drive the empty-on-all-transports
304
+ case without touching a console.
305
+ .OUTPUTS
306
+ System.Int32
307
+ #>
308
+ [CmdletBinding()]
309
+ [OutputType([int])]
310
+ param(
311
+ [AllowNull()]
312
+ [AllowEmptyString()]
313
+ [string] $ToolInputRaw,
314
+
315
+ [scriptblock] $ReadPayload = { Read-ClaudeHookRawPayload }
316
+ )
317
+
318
+ if (-not $PSBoundParameters.ContainsKey('ToolInputRaw')) {
319
+ $ToolInputRaw = [string](& $ReadPayload)
320
+ }
321
+
322
+ $decision = Invoke-EpicWaveBarrierDecision -ToolInputRaw $ToolInputRaw
323
+ $decision | ConvertTo-Json -Compress -Depth 5 | Write-Output
324
+
325
+ return 0
326
+ }
327
+
290
328
  # Guard allows dot-sourcing in tests without executing the entrypoint.
291
329
  if ($MyInvocation.InvocationName -eq '.') {
292
330
  return
293
331
  }
294
332
 
295
- try {
296
- $decision = Invoke-EpicWaveBarrierDecision -ToolInputRaw $env:CLAUDE_TOOL_INPUT
297
- } catch {
298
- Write-Error $_
299
- exit 1
300
- }
301
-
302
- $decision | ConvertTo-Json -Compress -Depth 5 | Write-Output
303
-
304
- exit 0
333
+ exit (Invoke-EpicWaveBarrierEntryPoint)
@@ -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/epic-orchestrator-state.json, and finds the features[] record
10
10
  whose worktree_path matches. Allows removal only when that record's merge_status is
@@ -21,6 +21,8 @@
21
21
  [CmdletBinding()]
22
22
  param()
23
23
 
24
+
25
+ Import-Module (Join-Path $PSScriptRoot '../lib/hook-payload/HookPayload.psm1') -Force
24
26
  $script:EpicCheckpointPath = 'artifacts/orchestration/epic-orchestrator-state.json'
25
27
  $script:AllowedMergeStatuses = @('merged', 'worktree_removed')
26
28
 
@@ -169,7 +171,7 @@ function Get-EpicWorktreeGateBlockDecision {
169
171
  function Invoke-EpicWorktreeRemovalGateDecision {
170
172
  <#
171
173
  .SYNOPSIS
172
- Parses CLAUDE_TOOL_INPUT and returns an allow-or-block decision.
174
+ Parses the PreToolUse envelope and returns an allow-or-block decision.
173
175
  .PARAMETER ToolInputRaw
174
176
  The raw JSON tool payload supplied by Claude Code.
175
177
  .OUTPUTS
@@ -181,17 +183,15 @@ function Invoke-EpicWorktreeRemovalGateDecision {
181
183
  [string] $ToolInputRaw
182
184
  )
183
185
 
184
- if (-not $ToolInputRaw) {
185
- return Get-EpicWorktreeGateAllowDecision
186
- }
187
-
188
- try {
189
- $toolInput = $ToolInputRaw | ConvertFrom-Json -ErrorAction Stop
190
- } catch {
191
- throw "enforce-epic-worktree-removal-gate hook received malformed JSON in CLAUDE_TOOL_INPUT: $_"
186
+ $payload = Resolve-ClaudeHookToolInput -Raw $ToolInputRaw
187
+ if (-not $payload.IsValid) {
188
+ return Get-EpicWorktreeGateBlockDecision -Reason (
189
+ 'EPIC_WORKTREE_REMOVAL_BLOCKED: payload anomaly - ' +
190
+ (Get-ClaudeHookPayloadAnomalyReason -Anomaly $payload.Anomaly) +
191
+ '. The gate fails closed on an envelope it cannot read.')
192
192
  }
193
193
 
194
- $commandText = $toolInput.command
194
+ $commandText = Get-ClaudeHookToolInputString -ToolInput $payload.Value -Name 'command'
195
195
  if (-not $commandText) {
196
196
  return Get-EpicWorktreeGateAllowDecision
197
197
  }
@@ -220,18 +220,55 @@ function Invoke-EpicWorktreeRemovalGateDecision {
220
220
  return Get-EpicWorktreeGateBlockDecision -Reason "EPIC_WORKTREE_REMOVAL_BLOCKED: git worktree remove for '$worktreePath' requires a matching epic checkpoint features[] 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."
221
221
  }
222
222
 
223
+ function Invoke-EpicWorktreeRemovalGateEntryPoint {
224
+ <#
225
+ .SYNOPSIS
226
+ Runs the hook decision and returns the process exit code.
227
+ .DESCRIPTION
228
+ Acquires the payload through the shared reader unless the caller supplies
229
+ one, emits the compact decision JSON, and returns 0. It never returns 1:
230
+ exit 1 is non-blocking for PreToolUse, so every anomaly is already a deny
231
+ decision by the time control reaches here. The function does not call exit;
232
+ the thin tail converts the returned code into a process exit.
233
+ .PARAMETER ToolInputRaw
234
+ Optional pre-acquired payload text. When omitted the ReadPayload seam runs.
235
+ .PARAMETER ReadPayload
236
+ Seam for payload acquisition, so tests can drive the empty-on-all-transports
237
+ case without touching a console.
238
+ .OUTPUTS
239
+ System.Int32
240
+ #>
241
+ [CmdletBinding()]
242
+ [OutputType([int])]
243
+ param(
244
+ [AllowNull()]
245
+ [AllowEmptyString()]
246
+ [string] $ToolInputRaw,
247
+
248
+ [scriptblock] $ReadPayload = { Read-ClaudeHookRawPayload }
249
+ )
250
+
251
+ if (-not $PSBoundParameters.ContainsKey('ToolInputRaw')) {
252
+ $ToolInputRaw = [string](& $ReadPayload)
253
+ }
254
+
255
+ $decision = Invoke-EpicWorktreeRemovalGateDecision -ToolInputRaw $ToolInputRaw
256
+ $decision | ConvertTo-Json -Compress -Depth 5 | Write-Output
257
+
258
+ return 0
259
+ }
260
+
223
261
  # Guard allows dot-sourcing in tests without executing the entrypoint.
224
262
  if ($MyInvocation.InvocationName -eq '.') {
225
263
  return
226
264
  }
227
265
 
228
- try {
229
- $decision = Invoke-EpicWorktreeRemovalGateDecision -ToolInputRaw $env:CLAUDE_TOOL_INPUT
230
- } catch {
231
- Write-Error $_
232
- exit 1
266
+ # The entry point returns its [int] exit code as the last pipeline element and the
267
+ # decision JSON before it. `exit (<call>)` would capture BOTH into the exit
268
+ # expression and emit nothing, so the decision is written explicitly here first.
269
+ $entryPointResult = @(Invoke-EpicWorktreeRemovalGateEntryPoint)
270
+ if ($entryPointResult.Count -gt 1) {
271
+ $entryPointResult[0..($entryPointResult.Count - 2)] | Write-Output
233
272
  }
234
273
 
235
- $decision | ConvertTo-Json -Compress -Depth 5 | Write-Output
236
-
237
- exit 0
274
+ exit ([int]$entryPointResult[-1])
@@ -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 a 'file_path' field) and rejects the operation when the target path is
9
- a non-canonical evidence location.
7
+ operation. It acquires the hook payload through the shared reader and reads
8
+ file_path from the envelope's nested tool_input, then rejects the operation when
9
+ the target path is a non-canonical evidence location.
10
10
 
11
11
  Forbidden path prefixes (case-sensitive, normalized to forward-slash):
12
12
  - artifacts/baselines/
@@ -31,7 +31,9 @@
31
31
  response to stdout with hookSpecificOutput.permissionDecision = 'deny' and exits with
32
32
  code 0 so Claude Code surfaces the reason. For allowed paths, a PreToolUse response
33
33
  with permissionDecision = 'allow' is written to stdout and the script exits 0. On hard
34
- failure (malformed JSON input), the script exits 1.
34
+ failure the script still exits 0 with a deny decision: exit 1 is non-blocking for
35
+ PreToolUse, so an envelope anomaly (empty payload, unparseable JSON, missing or
36
+ malformed tool_input) is emitted as a deny rather than raised.
35
37
 
36
38
  .NOTES
37
39
  Compatible with PowerShell 7+.
@@ -40,6 +42,8 @@
40
42
  [CmdletBinding()]
41
43
  param()
42
44
 
45
+ Import-Module (Join-Path $PSScriptRoot '../lib/hook-payload/HookPayload.psm1') -Force
46
+
43
47
  function Test-EvidenceLocationForbidden {
44
48
  <#
45
49
  .SYNOPSIS
@@ -104,32 +108,55 @@ function Get-EvidenceLocationBlockDecision {
104
108
  }
105
109
  }
106
110
 
111
+ function Get-EvidenceLocationAnomalyDecision {
112
+ <#
113
+ .SYNOPSIS
114
+ Build the fail-closed deny decision for an unreadable PreToolUse envelope.
115
+ .PARAMETER Anomaly
116
+ The anomaly code reported by the shared payload reader.
117
+ #>
118
+ [CmdletBinding()]
119
+ [OutputType([System.Collections.Specialized.OrderedDictionary])]
120
+ param(
121
+ [AllowNull()]
122
+ [AllowEmptyString()]
123
+ [string] $Anomaly
124
+ )
125
+
126
+ return [ordered]@{
127
+ hookSpecificOutput = [ordered]@{
128
+ hookEventName = 'PreToolUse'
129
+ permissionDecision = 'deny'
130
+ permissionDecisionReason = 'EVIDENCE_LOCATION_BLOCKED: payload anomaly - ' +
131
+ (Get-ClaudeHookPayloadAnomalyReason -Anomaly $Anomaly) +
132
+ '. The gate fails closed on an envelope it cannot read.'
133
+ }
134
+ }
135
+ }
136
+
107
137
  function Invoke-EvidenceLocationDecision {
108
138
  <#
109
139
  .SYNOPSIS
110
140
  Parses the Claude Code tool-input JSON and returns an allow-or-block decision.
111
141
  .PARAMETER ToolInputRaw
112
- The raw JSON string from $env:CLAUDE_TOOL_INPUT. An empty or null value
113
- results in an allow decision (non-file tool calls have no file_path).
142
+ The raw JSON hook payload acquired by Read-ClaudeHookRawPayload. An envelope
143
+ anomaly fails closed as a deny; a well-formed tool_input carrying no file_path
144
+ remains an allow (non-file tool calls have no file_path).
114
145
  #>
115
146
  [CmdletBinding()]
116
147
  [OutputType([System.Collections.Specialized.OrderedDictionary])]
117
148
  param(
149
+ [AllowNull()]
150
+ [AllowEmptyString()]
118
151
  [string] $ToolInputRaw
119
152
  )
120
153
 
121
- if (-not $ToolInputRaw) {
122
- return [ordered]@{ hookSpecificOutput = [ordered]@{ hookEventName = 'PreToolUse'; permissionDecision = 'allow' } }
154
+ $payload = Resolve-ClaudeHookToolInput -Raw $ToolInputRaw
155
+ if (-not $payload.IsValid) {
156
+ return Get-EvidenceLocationAnomalyDecision -Anomaly $payload.Anomaly
123
157
  }
124
158
 
125
- try {
126
- $toolInput = $ToolInputRaw | ConvertFrom-Json -ErrorAction Stop
127
- } catch {
128
- # Malformed JSON is a hard failure; caller exits 1 to surface the issue.
129
- throw "enforce-evidence-locations hook received malformed JSON in CLAUDE_TOOL_INPUT: $_"
130
- }
131
-
132
- $filePath = $toolInput.file_path
159
+ $filePath = Get-ClaudeHookToolInputString -ToolInput $payload.Value -Name 'file_path'
133
160
  if (-not $filePath) {
134
161
  return [ordered]@{ hookSpecificOutput = [ordered]@{ hookEventName = 'PreToolUse'; permissionDecision = 'allow' } }
135
162
  }
@@ -147,26 +174,34 @@ function Invoke-EvidenceLocationEntryPoint {
147
174
  Runs the evidence-location decision and returns the process exit code.
148
175
  .DESCRIPTION
149
176
  Wraps the dispatch logic that the hook entry point performs so it can be
150
- exercised by unit tests. On success it writes the compact JSON decision to
151
- the output stream and returns 0. On a hard failure (malformed JSON) it
152
- writes the error record and returns 1. This function does not call exit;
153
- the thin entry-point wiring converts the returned code into a process exit.
177
+ exercised by unit tests. It acquires the payload through the shared reader
178
+ unless the caller supplies one, writes the compact JSON decision to the output
179
+ stream, and returns 0. It never returns 1: exit 1 is non-blocking for
180
+ PreToolUse, so every envelope anomaly is already a deny decision by the time
181
+ control reaches here. This function does not call exit; the thin entry-point
182
+ wiring converts the returned code into a process exit.
154
183
  .PARAMETER ToolInputRaw
155
- The raw JSON string from $env:CLAUDE_TOOL_INPUT.
184
+ Optional pre-acquired payload text. When omitted the ReadPayload seam runs.
185
+ .PARAMETER ReadPayload
186
+ Seam for payload acquisition, so tests can drive the empty-on-all-transports
187
+ case without touching a console.
156
188
  #>
157
189
  [CmdletBinding()]
158
190
  [OutputType([int])]
159
191
  param(
160
- [string] $ToolInputRaw = $env:CLAUDE_TOOL_INPUT
192
+ [AllowNull()]
193
+ [AllowEmptyString()]
194
+ [string] $ToolInputRaw,
195
+
196
+ [scriptblock] $ReadPayload = { Read-ClaudeHookRawPayload }
161
197
  )
162
198
 
163
- try {
164
- $decision = Invoke-EvidenceLocationDecision -ToolInputRaw $ToolInputRaw
165
- } catch {
166
- Write-Error $_
167
- return 1
199
+ if (-not $PSBoundParameters.ContainsKey('ToolInputRaw')) {
200
+ $ToolInputRaw = [string](& $ReadPayload)
168
201
  }
169
202
 
203
+ $decision = Invoke-EvidenceLocationDecision -ToolInputRaw $ToolInputRaw
204
+
170
205
  $decision | ConvertTo-Json -Compress -Depth 5 | Write-Output
171
206
 
172
207
  return 0
@@ -177,4 +212,12 @@ if ($MyInvocation.InvocationName -eq '.') {
177
212
  return
178
213
  }
179
214
 
180
- exit (Invoke-EvidenceLocationEntryPoint)
215
+ # The entry point returns its [int] exit code as the last pipeline element and the
216
+ # decision JSON before it. `exit (<call>)` would capture BOTH into the exit
217
+ # expression and emit nothing, so the decision is written explicitly here first.
218
+ $entryPointResult = @(Invoke-EvidenceLocationEntryPoint)
219
+ if ($entryPointResult.Count -gt 1) {
220
+ $entryPointResult[0..($entryPointResult.Count - 2)] | Write-Output
221
+ }
222
+
223
+ exit ([int]$entryPointResult[-1])
@@ -4,8 +4,9 @@
4
4
  spec.md, or user-story.md are not yet present in that same folder.
5
5
 
6
6
  .DESCRIPTION
7
- Invoked by the Claude Code PreToolUse hook on Write or Edit operations. Reads
8
- tool input JSON from the CLAUDE_TOOL_INPUT environment variable. When the
7
+ Invoked by the Claude Code PreToolUse hook on Write or Edit operations. Acquires
8
+ the hook payload through the shared reader and reads file_path from the envelope's
9
+ nested tool_input. When the
9
10
  target file_path matches a feature-folder plan.md path under
10
11
  docs/features/(active|archive)/<folder>/plan.md, the script verifies that
11
12
  each of issue.md, spec.md, and user-story.md exists in the same folder.
@@ -24,6 +25,8 @@
24
25
  [CmdletBinding()]
25
26
  param()
26
27
 
28
+
29
+ Import-Module (Join-Path $PSScriptRoot '../lib/hook-payload/HookPayload.psm1') -Force
27
30
  function Get-FeatureFolderFileExistence {
28
31
  <#
29
32
  .SYNOPSIS
@@ -87,28 +90,34 @@ function Test-IsFeaturePlanPath {
87
90
  function Invoke-FeatureFolderOrderDecision {
88
91
  <#
89
92
  .SYNOPSIS
90
- Parses CLAUDE_TOOL_INPUT and produces an allow-or-block decision.
93
+ Parses the PreToolUse envelope and produces an allow-or-block decision.
91
94
  .PARAMETER ToolInputRaw
92
- Raw JSON string. Empty/null returns allow.
95
+ The raw JSON hook payload acquired by Read-ClaudeHookRawPayload. An envelope
96
+ anomaly fails closed as a deny; a well-formed tool_input carrying no file_path
97
+ remains an allow.
93
98
  #>
94
99
  [CmdletBinding()]
95
100
  [OutputType([System.Collections.Specialized.OrderedDictionary])]
96
101
  param(
102
+ [AllowNull()]
103
+ [AllowEmptyString()]
97
104
  [string] $ToolInputRaw
98
105
  )
99
106
 
100
- if (-not $ToolInputRaw) {
101
- return [ordered]@{ hookSpecificOutput = [ordered]@{ hookEventName = 'PreToolUse'; permissionDecision = 'allow' } }
102
- }
103
-
104
- try {
105
- $toolInput = $ToolInputRaw | ConvertFrom-Json -ErrorAction Stop
106
- }
107
- catch {
108
- throw "enforce-feature-folder-order hook received malformed JSON in CLAUDE_TOOL_INPUT: $_"
107
+ $payload = Resolve-ClaudeHookToolInput -Raw $ToolInputRaw
108
+ if (-not $payload.IsValid) {
109
+ return [ordered]@{
110
+ hookSpecificOutput = [ordered]@{
111
+ hookEventName = 'PreToolUse'
112
+ permissionDecision = 'deny'
113
+ permissionDecisionReason = 'FEATURE_FOLDER_ORDER_BLOCKED: payload anomaly - ' +
114
+ (Get-ClaudeHookPayloadAnomalyReason -Anomaly $payload.Anomaly) +
115
+ '. The gate fails closed on an envelope it cannot read.'
116
+ }
117
+ }
109
118
  }
110
119
 
111
- $filePath = $toolInput.file_path
120
+ $filePath = Get-ClaudeHookToolInputString -ToolInput $payload.Value -Name 'file_path'
112
121
  if (-not $filePath) {
113
122
  return [ordered]@{ hookSpecificOutput = [ordered]@{ hookEventName = 'PreToolUse'; permissionDecision = 'allow' } }
114
123
  }
@@ -134,19 +143,55 @@ function Invoke-FeatureFolderOrderDecision {
134
143
  }
135
144
  }
136
145
 
146
+ function Invoke-FeatureFolderOrderEntryPoint {
147
+ <#
148
+ .SYNOPSIS
149
+ Runs the hook decision and returns the process exit code.
150
+ .DESCRIPTION
151
+ Acquires the payload through the shared reader unless the caller supplies
152
+ one, emits the compact decision JSON, and returns 0. It never returns 1:
153
+ exit 1 is non-blocking for PreToolUse, so every anomaly is already a deny
154
+ decision by the time control reaches here. The function does not call exit;
155
+ the thin tail converts the returned code into a process exit.
156
+ .PARAMETER ToolInputRaw
157
+ Optional pre-acquired payload text. When omitted the ReadPayload seam runs.
158
+ .PARAMETER ReadPayload
159
+ Seam for payload acquisition, so tests can drive the empty-on-all-transports
160
+ case without touching a console.
161
+ .OUTPUTS
162
+ System.Int32
163
+ #>
164
+ [CmdletBinding()]
165
+ [OutputType([int])]
166
+ param(
167
+ [AllowNull()]
168
+ [AllowEmptyString()]
169
+ [string] $ToolInputRaw,
170
+
171
+ [scriptblock] $ReadPayload = { Read-ClaudeHookRawPayload }
172
+ )
173
+
174
+ if (-not $PSBoundParameters.ContainsKey('ToolInputRaw')) {
175
+ $ToolInputRaw = [string](& $ReadPayload)
176
+ }
177
+
178
+ $decision = Invoke-FeatureFolderOrderDecision -ToolInputRaw $ToolInputRaw
179
+ $decision | ConvertTo-Json -Compress -Depth 5 | Write-Output
180
+
181
+ return 0
182
+ }
183
+
137
184
  # Guard allows dot-sourcing in tests without executing the entrypoint.
138
185
  if ($MyInvocation.InvocationName -eq '.') {
139
186
  return
140
187
  }
141
188
 
142
- try {
143
- $decision = Invoke-FeatureFolderOrderDecision -ToolInputRaw $env:CLAUDE_TOOL_INPUT
189
+ # The entry point returns its [int] exit code as the last pipeline element and the
190
+ # decision JSON before it. `exit (<call>)` would capture BOTH into the exit
191
+ # expression and emit nothing, so the decision is written explicitly here first.
192
+ $entryPointResult = @(Invoke-FeatureFolderOrderEntryPoint)
193
+ if ($entryPointResult.Count -gt 1) {
194
+ $entryPointResult[0..($entryPointResult.Count - 2)] | Write-Output
144
195
  }
145
- catch {
146
- Write-Error $_
147
- exit 1
148
- }
149
-
150
- $decision | ConvertTo-Json -Compress -Depth 5 | Write-Output
151
196
 
152
- exit 0
197
+ exit ([int]$entryPointResult[-1])