@danmoisan/drm-copilot-mcp 1.0.0 → 1.0.7

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 (52) hide show
  1. package/out/mcp-server.js +814 -194
  2. package/package.json +1 -1
  3. package/resources/claude-customizations/.claude/agents/commit-message.md +42 -0
  4. package/resources/claude-customizations/.claude/agents/epic-orchestrator.md +127 -0
  5. package/resources/claude-customizations/.claude/agents/human-exception-runbook.md +48 -0
  6. package/resources/claude-customizations/.claude/agents/orchestrator.md +67 -2
  7. package/resources/claude-customizations/.claude/agents/pr-author.md +37 -21
  8. package/resources/claude-customizations/.claude/hooks/check-powershell-test-purity.ps1 +103 -65
  9. package/resources/claude-customizations/.claude/hooks/check-python-test-purity.ps1 +12 -9
  10. package/resources/claude-customizations/.claude/hooks/enforce-checkpoint-monotonic.ps1 +24 -16
  11. package/resources/claude-customizations/.claude/hooks/enforce-completion-consistency.ps1 +19 -13
  12. package/resources/claude-customizations/.claude/hooks/enforce-epic-merge-gate.ps1 +304 -0
  13. package/resources/claude-customizations/.claude/hooks/enforce-epic-wave-barrier.ps1 +304 -0
  14. package/resources/claude-customizations/.claude/hooks/enforce-epic-worktree-removal-gate.ps1 +237 -0
  15. package/resources/claude-customizations/.claude/hooks/enforce-evidence-locations.ps1 +16 -12
  16. package/resources/claude-customizations/.claude/hooks/enforce-feature-folder-order.ps1 +14 -10
  17. package/resources/claude-customizations/.claude/hooks/enforce-orchestration-preimplementation-gate.ps1 +35 -8
  18. package/resources/claude-customizations/.claude/hooks/enforce-powershell-batch-budget.ps1 +17 -14
  19. package/resources/claude-customizations/.claude/hooks/enforce-pr-author-skill.epic-base-branch.ps1 +104 -0
  20. package/resources/claude-customizations/.claude/hooks/enforce-pr-author-skill.ps1 +268 -93
  21. package/resources/claude-customizations/.claude/hooks/enforce-prd-feature-before-planner.ps1 +19 -11
  22. package/resources/claude-customizations/.claude/hooks/enforce-promotion-mcp-only.ps1 +28 -6
  23. package/resources/claude-customizations/.claude/hooks/enforce-python-batch-budget.ps1 +17 -14
  24. package/resources/claude-customizations/.claude/hooks/validate-bash.ps1 +146 -36
  25. package/resources/claude-customizations/.claude/hooks/validate-orchestrator-output.ps1 +19 -7
  26. package/resources/claude-customizations/.claude/rules/orchestrator-state.md +36 -0
  27. package/resources/claude-customizations/.claude/settings.json +34 -1
  28. package/resources/claude-customizations/.claude/skills/epic-orchestrate/SKILL.md +252 -0
  29. package/resources/claude-customizations/.claude/skills/orchestrate/SKILL.md +42 -13
  30. package/resources/claude-customizations/.claude/skills/pr-author/SKILL.md +28 -0
  31. package/resources/claude-customizations/.claude/skills/remediation-handoff-atomic-planner/SKILL.md +22 -18
  32. package/resources/claude-customizations/pack-manifests/core.json +8 -0
  33. package/resources/codex-and-agents-customizations/.agents/skills/orchestrate/SKILL.md +6 -3
  34. package/resources/codex-and-agents-customizations/.agents/skills/remediation-handoff-atomic-planner/SKILL.md +1 -1
  35. package/resources/codex-and-agents-customizations/.agents-variants/csharp-legacy/skills/csharp/SKILL.md +65 -0
  36. package/resources/codex-and-agents-customizations/.agents-variants/csharp-legacy/skills/csharp-qa-gate/SKILL.md +79 -0
  37. package/resources/codex-and-agents-customizations/.agents-variants/csharp-legacy/skills/invoke-csharp-engineer/SKILL.md +66 -0
  38. package/resources/codex-and-agents-customizations/.codex/config.toml +1 -25
  39. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-pr-author-skill.ps1 +257 -93
  40. package/resources/codex-and-agents-customizations/.codex-variants/csharp-legacy/agents/csharp-typed-engineer.toml +98 -0
  41. package/resources/codex-and-agents-customizations/pack-manifests/core.json +24 -0
  42. package/resources/codex-and-agents-customizations/pack-manifests/csharp-legacy.json +11 -0
  43. package/resources/codex-and-agents-customizations/pack-manifests/csharp-modern.json +10 -0
  44. package/resources/codex-and-agents-customizations/pack-manifests/powershell.json +15 -0
  45. package/resources/codex-and-agents-customizations/pack-manifests/python.json +15 -0
  46. package/resources/codex-and-agents-customizations/pack-manifests/typescript.json +9 -0
  47. package/resources/config/orchestration-routing.json +92 -0
  48. package/resources/customizations/.github/agents/pr-author.agent.md +24 -18
  49. package/resources/icon.png +0 -0
  50. package/resources/powershell/PoshQC/settings/pester.runsettings.psd1 +11 -0
  51. package/resources/codex-and-agents-customizations/.github/workflows/_validate-orchestrator-state.yml +0 -68
  52. package/resources/codex-and-agents-customizations/.github/workflows/validate-orchestrator-state.yml +0 -15
@@ -0,0 +1,304 @@
1
+ <#
2
+ .SYNOPSIS
3
+ Pre-tool-use hook that is the Layer 1 per-call deterrent for the epic wave barrier.
4
+
5
+ .DESCRIPTION
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"
8
+ and the serialized prompt contains the epic-mode kickoff marker "Epic mode: true".
9
+
10
+ Resolution and decision procedure:
11
+ 1. Resolve the target child feature_folder from the prompt text by scanning for a
12
+ docs/features/active/<token> path, mirroring
13
+ enforce-prd-feature-before-planner.ps1's Find-PrdFeatureFolderFromPrompt
14
+ technique (longest match wins; a .md-suffixed match uses its parent directory).
15
+ 2. Read artifacts/orchestration/epic-orchestrator-state.json and locate the
16
+ features[] record whose feature_folder equals the resolved basename.
17
+ 3. Look up that feature's depends_on list, and for every dependency, locate its own
18
+ features[] record.
19
+ 4. Deny with reason EPIC_WAVE_BARRIER_BLOCKED unless every dependency's merge_status
20
+ is merged or worktree_removed. A missing/unreadable checkpoint, an unresolved
21
+ target feature_folder, or a missing dependency record also denies (fail-closed).
22
+
23
+ This is the per-call deterrent (Layer 1) of the two-layer wave-barrier design; the
24
+ retrospective backstop (Layer 2) is the wave-barrier ordering invariant inside
25
+ validate_epic_orchestrator_state_text, enforced separately at epic-orchestrator
26
+ SubagentStop time.
27
+
28
+ .NOTES
29
+ Compatible with PowerShell 7+. No external module dependencies. Filesystem reads go
30
+ through an injectable wrapper function so tests can mock the boundary without writing
31
+ temporary files.
32
+ #>
33
+ [CmdletBinding()]
34
+ param()
35
+
36
+ $script:EpicCheckpointPath = 'artifacts/orchestration/epic-orchestrator-state.json'
37
+ $script:AllowedMergeStatuses = @('merged', 'worktree_removed')
38
+ $script:EpicModeMarker = 'Epic mode: true'
39
+
40
+ function Get-EpicWaveBarrierCheckpointContent {
41
+ <#
42
+ .SYNOPSIS
43
+ Read the raw JSON text of the epic checkpoint. Tests mock this function
44
+ (read seam).
45
+ .OUTPUTS
46
+ System.String or $null
47
+ #>
48
+ [CmdletBinding()]
49
+ [OutputType([string])]
50
+ param()
51
+
52
+ if (-not (Test-Path -LiteralPath $script:EpicCheckpointPath -PathType Leaf)) {
53
+ return $null
54
+ }
55
+ return (Get-Content -LiteralPath $script:EpicCheckpointPath -Raw)
56
+ }
57
+
58
+ function Find-EpicWaveBarrierFeatureFolderFromPrompt {
59
+ <#
60
+ .SYNOPSIS
61
+ Scans a prompt string for docs/features/active/<...> path tokens and returns the
62
+ longest unique match's basename. Returns $null when no match is found.
63
+ .DESCRIPTION
64
+ Mirrors enforce-prd-feature-before-planner.ps1's
65
+ Find-PrdFeatureFolderFromPrompt technique: forward- or backslash-separated path
66
+ tokens are accepted, the longest match wins, and a .md-suffixed match resolves to
67
+ its parent directory before the basename is extracted.
68
+ .PARAMETER Prompt
69
+ The delegation prompt text under evaluation.
70
+ .OUTPUTS
71
+ System.String or $null
72
+ #>
73
+ [CmdletBinding()]
74
+ [OutputType([string])]
75
+ param(
76
+ [Parameter(Mandatory)]
77
+ [AllowEmptyString()]
78
+ [string] $Prompt
79
+ )
80
+
81
+ if (-not $Prompt) {
82
+ return $null
83
+ }
84
+
85
+ $pattern = 'docs[\\/]+features[\\/]+active[\\/]+[^\s"''`]+'
86
+ $matchList = [regex]::Matches($Prompt, $pattern)
87
+ if ($matchList.Count -eq 0) {
88
+ return $null
89
+ }
90
+
91
+ $unique = @{}
92
+ foreach ($m in $matchList) {
93
+ $normalized = ($m.Value -replace '\\', '/').TrimEnd('/')
94
+ $unique[$normalized] = $true
95
+ }
96
+
97
+ $candidates = @(@($unique.Keys) | Sort-Object -Property Length -Descending)
98
+ $best = $candidates[0]
99
+
100
+ if ($best -match '\.md$') {
101
+ $best = $best -replace '/[^/]+\.md$', ''
102
+ }
103
+
104
+ return ($best -split '/')[-1]
105
+ }
106
+
107
+ function Find-EpicWaveBarrierFeatureRecord {
108
+ <#
109
+ .SYNOPSIS
110
+ Locate the features[] record whose feature_folder equals the target basename.
111
+ .PARAMETER Checkpoint
112
+ Parsed epic checkpoint, or $null when absent/unreadable.
113
+ .PARAMETER FeatureFolder
114
+ The target feature_folder basename.
115
+ .OUTPUTS
116
+ System.Object or $null
117
+ #>
118
+ [CmdletBinding()]
119
+ param(
120
+ [AllowNull()]
121
+ $Checkpoint,
122
+
123
+ [AllowNull()]
124
+ [string] $FeatureFolder
125
+ )
126
+
127
+ if ($null -eq $Checkpoint -or [string]::IsNullOrWhiteSpace($FeatureFolder)) {
128
+ return $null
129
+ }
130
+ $checkpointProps = @($Checkpoint.PSObject.Properties.Name)
131
+ if ($checkpointProps -notcontains 'features') {
132
+ return $null
133
+ }
134
+
135
+ # Scan every recorded feature for a feature_folder that equals the target basename.
136
+ foreach ($feature in @($Checkpoint.features)) {
137
+ $featureProps = @($feature.PSObject.Properties.Name)
138
+ if ($featureProps -notcontains 'feature_folder') {
139
+ continue
140
+ }
141
+ if (([string]$feature.feature_folder) -eq $FeatureFolder) {
142
+ return $feature
143
+ }
144
+ }
145
+ return $null
146
+ }
147
+
148
+ function Test-EpicWaveBarrierDependenciesMerged {
149
+ <#
150
+ .SYNOPSIS
151
+ Decision logic: true only when every dependency's merge_status is merged or
152
+ worktree_removed.
153
+ .PARAMETER Checkpoint
154
+ Parsed epic checkpoint, or $null when absent/unreadable.
155
+ .PARAMETER FeatureRecord
156
+ The target feature's own features[] record, or $null when not found.
157
+ .OUTPUTS
158
+ System.Boolean
159
+ #>
160
+ [CmdletBinding()]
161
+ [OutputType([bool])]
162
+ param(
163
+ [AllowNull()]
164
+ $Checkpoint,
165
+
166
+ [AllowNull()]
167
+ $FeatureRecord
168
+ )
169
+
170
+ if ($null -eq $Checkpoint -or $null -eq $FeatureRecord) {
171
+ return $false
172
+ }
173
+ $featureProps = @($FeatureRecord.PSObject.Properties.Name)
174
+ if ($featureProps -notcontains 'depends_on') {
175
+ # No dependencies recorded: an empty/absent depends_on list has nothing to block on.
176
+ return $true
177
+ }
178
+
179
+ $dependsOn = @($FeatureRecord.depends_on)
180
+ if ($dependsOn.Count -eq 0) {
181
+ return $true
182
+ }
183
+
184
+ # Every dependency edge must be durably confirmed merged or worktree_removed before
185
+ # this wave's feature is allowed to start.
186
+ foreach ($dependencyFolder in $dependsOn) {
187
+ $dependencyRecord = Find-EpicWaveBarrierFeatureRecord -Checkpoint $Checkpoint -FeatureFolder ([string]$dependencyFolder)
188
+ if ($null -eq $dependencyRecord) {
189
+ return $false
190
+ }
191
+ $dependencyProps = @($dependencyRecord.PSObject.Properties.Name)
192
+ if ($dependencyProps -notcontains 'merge_status') {
193
+ return $false
194
+ }
195
+ if ($script:AllowedMergeStatuses -notcontains ([string]$dependencyRecord.merge_status)) {
196
+ return $false
197
+ }
198
+ }
199
+ return $true
200
+ }
201
+
202
+ function Get-EpicWaveBarrierAllowDecision {
203
+ [CmdletBinding()]
204
+ [OutputType([System.Collections.Specialized.OrderedDictionary])]
205
+ param()
206
+
207
+ return [ordered]@{
208
+ hookSpecificOutput = [ordered]@{
209
+ hookEventName = 'PreToolUse'
210
+ permissionDecision = 'allow'
211
+ }
212
+ }
213
+ }
214
+
215
+ function Get-EpicWaveBarrierBlockDecision {
216
+ [CmdletBinding()]
217
+ [OutputType([System.Collections.Specialized.OrderedDictionary])]
218
+ param(
219
+ [Parameter(Mandatory)]
220
+ [string] $Reason
221
+ )
222
+
223
+ return [ordered]@{
224
+ hookSpecificOutput = [ordered]@{
225
+ hookEventName = 'PreToolUse'
226
+ permissionDecision = 'deny'
227
+ permissionDecisionReason = $Reason
228
+ }
229
+ }
230
+ }
231
+
232
+ function Invoke-EpicWaveBarrierDecision {
233
+ <#
234
+ .SYNOPSIS
235
+ Parses CLAUDE_TOOL_INPUT and returns an allow-or-block decision.
236
+ .PARAMETER ToolInputRaw
237
+ The raw JSON tool payload supplied by Claude Code.
238
+ .OUTPUTS
239
+ System.Collections.Specialized.OrderedDictionary
240
+ #>
241
+ [CmdletBinding()]
242
+ [OutputType([System.Collections.Specialized.OrderedDictionary])]
243
+ param(
244
+ [string] $ToolInputRaw
245
+ )
246
+
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: $_"
255
+ }
256
+
257
+ $subagent = $toolInput.subagent_type
258
+ if (-not $subagent -or $subagent -ne 'orchestrator') {
259
+ return Get-EpicWaveBarrierAllowDecision
260
+ }
261
+
262
+ $prompt = [string]$toolInput.prompt
263
+ if (-not $prompt -or $prompt -notlike "*$script:EpicModeMarker*") {
264
+ return Get-EpicWaveBarrierAllowDecision
265
+ }
266
+
267
+ $featureFolder = Find-EpicWaveBarrierFeatureFolderFromPrompt -Prompt $prompt
268
+ if (-not $featureFolder) {
269
+ return Get-EpicWaveBarrierBlockDecision -Reason 'EPIC_WAVE_BARRIER_BLOCKED: an epic-mode orchestrator delegation must reference the target feature folder in the prompt so its dependency edges can be verified.'
270
+ }
271
+
272
+ $checkpointRaw = Get-EpicWaveBarrierCheckpointContent
273
+ $checkpoint = $null
274
+ if (-not [string]::IsNullOrWhiteSpace($checkpointRaw)) {
275
+ try {
276
+ $checkpoint = $checkpointRaw | ConvertFrom-Json -ErrorAction Stop
277
+ } catch {
278
+ $checkpoint = $null
279
+ }
280
+ }
281
+
282
+ $featureRecord = Find-EpicWaveBarrierFeatureRecord -Checkpoint $checkpoint -FeatureFolder $featureFolder
283
+ if (Test-EpicWaveBarrierDependenciesMerged -Checkpoint $checkpoint -FeatureRecord $featureRecord) {
284
+ return Get-EpicWaveBarrierAllowDecision
285
+ }
286
+
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
+ }
289
+
290
+ # Guard allows dot-sourcing in tests without executing the entrypoint.
291
+ if ($MyInvocation.InvocationName -eq '.') {
292
+ return
293
+ }
294
+
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
@@ -0,0 +1,237 @@
1
+ <#
2
+ .SYNOPSIS
3
+ Pre-tool-use hook that gates git worktree remove behind epic checkpoint merge state.
4
+
5
+ .DESCRIPTION
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,
8
+ extracts the target worktree path argument, reads
9
+ artifacts/orchestration/epic-orchestrator-state.json, and finds the features[] record
10
+ whose worktree_path matches. Allows removal only when that record's merge_status is
11
+ merged or worktree_removed. Denies with reason EPIC_WORKTREE_REMOVAL_BLOCKED when the
12
+ checkpoint is unreadable, no matching record exists, or merge_status is anything else -
13
+ fail-closed, following the enforce-orchestration-preimplementation-gate.ps1 precedent of
14
+ treating an unreadable/no-match checkpoint as deny.
15
+
16
+ .NOTES
17
+ Compatible with PowerShell 7+. No external module dependencies. Filesystem reads go
18
+ through an injectable wrapper function so tests can mock the boundary without writing
19
+ temporary files.
20
+ #>
21
+ [CmdletBinding()]
22
+ param()
23
+
24
+ $script:EpicCheckpointPath = 'artifacts/orchestration/epic-orchestrator-state.json'
25
+ $script:AllowedMergeStatuses = @('merged', 'worktree_removed')
26
+
27
+ function Get-EpicWorktreeGateCheckpointContent {
28
+ <#
29
+ .SYNOPSIS
30
+ Read the raw JSON text of the epic checkpoint. Tests mock this function
31
+ (read seam).
32
+ .OUTPUTS
33
+ System.String or $null
34
+ #>
35
+ [CmdletBinding()]
36
+ [OutputType([string])]
37
+ param()
38
+
39
+ if (-not (Test-Path -LiteralPath $script:EpicCheckpointPath -PathType Leaf)) {
40
+ return $null
41
+ }
42
+ return (Get-Content -LiteralPath $script:EpicCheckpointPath -Raw)
43
+ }
44
+
45
+ function Get-EpicWorktreeRemovalCommandPath {
46
+ <#
47
+ .SYNOPSIS
48
+ Extract the target worktree path argument from a git worktree remove command.
49
+ .PARAMETER CommandText
50
+ The Bash command text under evaluation.
51
+ .OUTPUTS
52
+ System.String or $null
53
+ #>
54
+ [CmdletBinding()]
55
+ [OutputType([string])]
56
+ param(
57
+ [Parameter(Mandatory)]
58
+ [string] $CommandText
59
+ )
60
+
61
+ if ($CommandText -match '(?i)\bgit\s+worktree\s+remove\s+(?<path>\S+)') {
62
+ return $Matches['path'].Trim('"''')
63
+ }
64
+ return $null
65
+ }
66
+
67
+ function Find-EpicWorktreeFeatureRecord {
68
+ <#
69
+ .SYNOPSIS
70
+ Locate the features[] record whose worktree_path matches the target path.
71
+ .PARAMETER Checkpoint
72
+ Parsed epic checkpoint, or $null when absent/unreadable.
73
+ .PARAMETER WorktreePath
74
+ The target worktree path extracted from the command text.
75
+ .OUTPUTS
76
+ System.Object or $null
77
+ #>
78
+ [CmdletBinding()]
79
+ param(
80
+ [AllowNull()]
81
+ $Checkpoint,
82
+
83
+ [AllowNull()]
84
+ [string] $WorktreePath
85
+ )
86
+
87
+ if ($null -eq $Checkpoint -or [string]::IsNullOrWhiteSpace($WorktreePath)) {
88
+ return $null
89
+ }
90
+ $checkpointProps = @($Checkpoint.PSObject.Properties.Name)
91
+ if ($checkpointProps -notcontains 'features') {
92
+ return $null
93
+ }
94
+
95
+ $normalizedTarget = ($WorktreePath -replace '\\', '/').TrimEnd('/')
96
+
97
+ # Scan every recorded feature for a worktree_path that matches the removal target;
98
+ # path separators are normalized so Windows- and POSIX-style paths compare equal.
99
+ foreach ($feature in @($Checkpoint.features)) {
100
+ $featureProps = @($feature.PSObject.Properties.Name)
101
+ if ($featureProps -notcontains 'worktree_path') {
102
+ continue
103
+ }
104
+ $normalizedFeaturePath = (([string]$feature.worktree_path) -replace '\\', '/').TrimEnd('/')
105
+ if ($normalizedFeaturePath -eq $normalizedTarget) {
106
+ return $feature
107
+ }
108
+ }
109
+ return $null
110
+ }
111
+
112
+ function Test-EpicWorktreeRemovalAllowed {
113
+ <#
114
+ .SYNOPSIS
115
+ Decision logic: allow only when the matching feature record's merge_status is
116
+ merged or worktree_removed.
117
+ .PARAMETER FeatureRecord
118
+ The matched features[] record, or $null when no match was found.
119
+ .OUTPUTS
120
+ System.Boolean
121
+ #>
122
+ [CmdletBinding()]
123
+ [OutputType([bool])]
124
+ param(
125
+ [AllowNull()]
126
+ $FeatureRecord
127
+ )
128
+
129
+ if ($null -eq $FeatureRecord) {
130
+ return $false
131
+ }
132
+ $props = @($FeatureRecord.PSObject.Properties.Name)
133
+ if ($props -notcontains 'merge_status') {
134
+ return $false
135
+ }
136
+ return $script:AllowedMergeStatuses -contains ([string]$FeatureRecord.merge_status)
137
+ }
138
+
139
+ function Get-EpicWorktreeGateAllowDecision {
140
+ [CmdletBinding()]
141
+ [OutputType([System.Collections.Specialized.OrderedDictionary])]
142
+ param()
143
+
144
+ return [ordered]@{
145
+ hookSpecificOutput = [ordered]@{
146
+ hookEventName = 'PreToolUse'
147
+ permissionDecision = 'allow'
148
+ }
149
+ }
150
+ }
151
+
152
+ function Get-EpicWorktreeGateBlockDecision {
153
+ [CmdletBinding()]
154
+ [OutputType([System.Collections.Specialized.OrderedDictionary])]
155
+ param(
156
+ [Parameter(Mandatory)]
157
+ [string] $Reason
158
+ )
159
+
160
+ return [ordered]@{
161
+ hookSpecificOutput = [ordered]@{
162
+ hookEventName = 'PreToolUse'
163
+ permissionDecision = 'deny'
164
+ permissionDecisionReason = $Reason
165
+ }
166
+ }
167
+ }
168
+
169
+ function Invoke-EpicWorktreeRemovalGateDecision {
170
+ <#
171
+ .SYNOPSIS
172
+ Parses CLAUDE_TOOL_INPUT and returns an allow-or-block decision.
173
+ .PARAMETER ToolInputRaw
174
+ The raw JSON tool payload supplied by Claude Code.
175
+ .OUTPUTS
176
+ System.Collections.Specialized.OrderedDictionary
177
+ #>
178
+ [CmdletBinding()]
179
+ [OutputType([System.Collections.Specialized.OrderedDictionary])]
180
+ param(
181
+ [string] $ToolInputRaw
182
+ )
183
+
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: $_"
192
+ }
193
+
194
+ $commandText = $toolInput.command
195
+ if (-not $commandText) {
196
+ return Get-EpicWorktreeGateAllowDecision
197
+ }
198
+
199
+ if ($commandText -notmatch '(?i)\bgit\s+worktree\s+remove\b') {
200
+ return Get-EpicWorktreeGateAllowDecision
201
+ }
202
+
203
+ $worktreePath = Get-EpicWorktreeRemovalCommandPath -CommandText $commandText
204
+
205
+ $checkpointRaw = Get-EpicWorktreeGateCheckpointContent
206
+ $checkpoint = $null
207
+ if (-not [string]::IsNullOrWhiteSpace($checkpointRaw)) {
208
+ try {
209
+ $checkpoint = $checkpointRaw | ConvertFrom-Json -ErrorAction Stop
210
+ } catch {
211
+ $checkpoint = $null
212
+ }
213
+ }
214
+
215
+ $featureRecord = Find-EpicWorktreeFeatureRecord -Checkpoint $checkpoint -WorktreePath $worktreePath
216
+ if (Test-EpicWorktreeRemovalAllowed -FeatureRecord $featureRecord) {
217
+ return Get-EpicWorktreeGateAllowDecision
218
+ }
219
+
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
+ }
222
+
223
+ # Guard allows dot-sourcing in tests without executing the entrypoint.
224
+ if ($MyInvocation.InvocationName -eq '.') {
225
+ return
226
+ }
227
+
228
+ try {
229
+ $decision = Invoke-EpicWorktreeRemovalGateDecision -ToolInputRaw $env:CLAUDE_TOOL_INPUT
230
+ } catch {
231
+ Write-Error $_
232
+ exit 1
233
+ }
234
+
235
+ $decision | ConvertTo-Json -Compress -Depth 5 | Write-Output
236
+
237
+ exit 0
@@ -27,10 +27,11 @@
27
27
  is written to the tracked roots docs/features/<feature>/research/
28
28
  (feature-associated) or docs/research/ (one-off).
29
29
 
30
- If the file_path resolves to a forbidden prefix, the script writes a JSON response
31
- to stdout with 'decision': 'block' and exits with code 0 so Claude Code surfaces
32
- the reason. For allowed paths, 'decision': 'allow' is written to stdout and the
33
- script exits 0. On hard failure (malformed JSON input), the script exits 1.
30
+ If the file_path resolves to a forbidden prefix, the script writes a PreToolUse JSON
31
+ response to stdout with hookSpecificOutput.permissionDecision = 'deny' and exits with
32
+ code 0 so Claude Code surfaces the reason. For allowed paths, a PreToolUse response
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
35
 
35
36
  .NOTES
36
37
  Compatible with PowerShell 7+.
@@ -83,9 +84,9 @@ function Test-EvidenceLocationForbidden {
83
84
  function Get-EvidenceLocationBlockDecision {
84
85
  <#
85
86
  .SYNOPSIS
86
- Constructs a block-decision ordered dictionary for the supplied forbidden path.
87
+ Constructs a deny-decision ordered dictionary for the supplied forbidden path.
87
88
  .PARAMETER FilePath
88
- The file path that triggered the block.
89
+ The file path that triggered the deny decision.
89
90
  #>
90
91
  [CmdletBinding()]
91
92
  [OutputType([System.Collections.Specialized.OrderedDictionary])]
@@ -95,8 +96,11 @@ function Get-EvidenceLocationBlockDecision {
95
96
  )
96
97
 
97
98
  [ordered]@{
98
- decision = 'block'
99
- reason = "EVIDENCE_LOCATION_BLOCKED: '$FilePath' is not a canonical evidence location. Use <FEATURE>/evidence/<kind>/ instead. See .claude/skills/evidence-and-timestamp-conventions/SKILL.md for the canonical scheme."
99
+ hookSpecificOutput = [ordered]@{
100
+ hookEventName = 'PreToolUse'
101
+ permissionDecision = 'deny'
102
+ permissionDecisionReason = "EVIDENCE_LOCATION_BLOCKED: '$FilePath' is not a canonical evidence location. Use <FEATURE>/evidence/<kind>/ instead. See .claude/skills/evidence-and-timestamp-conventions/SKILL.md for the canonical scheme."
103
+ }
100
104
  }
101
105
  }
102
106
 
@@ -115,7 +119,7 @@ function Invoke-EvidenceLocationDecision {
115
119
  )
116
120
 
117
121
  if (-not $ToolInputRaw) {
118
- return [ordered]@{ decision = 'allow' }
122
+ return [ordered]@{ hookSpecificOutput = [ordered]@{ hookEventName = 'PreToolUse'; permissionDecision = 'allow' } }
119
123
  }
120
124
 
121
125
  try {
@@ -127,14 +131,14 @@ function Invoke-EvidenceLocationDecision {
127
131
 
128
132
  $filePath = $toolInput.file_path
129
133
  if (-not $filePath) {
130
- return [ordered]@{ decision = 'allow' }
134
+ return [ordered]@{ hookSpecificOutput = [ordered]@{ hookEventName = 'PreToolUse'; permissionDecision = 'allow' } }
131
135
  }
132
136
 
133
137
  if (Test-EvidenceLocationForbidden -FilePath $filePath) {
134
138
  return Get-EvidenceLocationBlockDecision -FilePath $filePath
135
139
  }
136
140
 
137
- return [ordered]@{ decision = 'allow' }
141
+ return [ordered]@{ hookSpecificOutput = [ordered]@{ hookEventName = 'PreToolUse'; permissionDecision = 'allow' } }
138
142
  }
139
143
 
140
144
  function Invoke-EvidenceLocationEntryPoint {
@@ -163,7 +167,7 @@ function Invoke-EvidenceLocationEntryPoint {
163
167
  return 1
164
168
  }
165
169
 
166
- $decision | ConvertTo-Json -Compress | Write-Output
170
+ $decision | ConvertTo-Json -Compress -Depth 5 | Write-Output
167
171
 
168
172
  return 0
169
173
  }
@@ -10,9 +10,10 @@
10
10
  docs/features/(active|archive)/<folder>/plan.md, the script verifies that
11
11
  each of issue.md, spec.md, and user-story.md exists in the same folder.
12
12
 
13
- If any of the three sibling files is missing, the script emits a JSON
14
- response with decision='block' and exits 0 so Claude Code surfaces the
15
- reason. All other paths pass through with decision='allow'.
13
+ If any of the three sibling files is missing, the script emits a PreToolUse
14
+ JSON response with hookSpecificOutput.permissionDecision='deny' and exits 0 so
15
+ Claude Code surfaces the reason. All other paths pass through with
16
+ permissionDecision='allow'.
16
17
 
17
18
  Filesystem reads go through Get-FeatureFolderFileExistence so tests can
18
19
  inject a fake without touching disk.
@@ -97,7 +98,7 @@ function Invoke-FeatureFolderOrderDecision {
97
98
  )
98
99
 
99
100
  if (-not $ToolInputRaw) {
100
- return [ordered]@{ decision = 'allow' }
101
+ return [ordered]@{ hookSpecificOutput = [ordered]@{ hookEventName = 'PreToolUse'; permissionDecision = 'allow' } }
101
102
  }
102
103
 
103
104
  try {
@@ -109,24 +110,27 @@ function Invoke-FeatureFolderOrderDecision {
109
110
 
110
111
  $filePath = $toolInput.file_path
111
112
  if (-not $filePath) {
112
- return [ordered]@{ decision = 'allow' }
113
+ return [ordered]@{ hookSpecificOutput = [ordered]@{ hookEventName = 'PreToolUse'; permissionDecision = 'allow' } }
113
114
  }
114
115
 
115
116
  $normalized = $filePath -replace '\\', '/'
116
117
 
117
118
  if (-not (Test-IsFeaturePlanPath -NormalizedPath $normalized)) {
118
- return [ordered]@{ decision = 'allow' }
119
+ return [ordered]@{ hookSpecificOutput = [ordered]@{ hookEventName = 'PreToolUse'; permissionDecision = 'allow' } }
119
120
  }
120
121
 
121
122
  $missing = Get-FeatureFolderMissingFile -PlanFilePath $normalized
122
123
  if ($missing.Count -eq 0) {
123
- return [ordered]@{ decision = 'allow' }
124
+ return [ordered]@{ hookSpecificOutput = [ordered]@{ hookEventName = 'PreToolUse'; permissionDecision = 'allow' } }
124
125
  }
125
126
 
126
127
  $list = ($missing -join ', ')
127
128
  return [ordered]@{
128
- decision = 'block'
129
- reason = "FEATURE_FOLDER_ORDER_BLOCKED: cannot write plan.md before producing prerequisite documents. Missing in feature folder: $list. Invoke the prd-feature subagent to generate the missing file(s) before authoring plan.md."
129
+ hookSpecificOutput = [ordered]@{
130
+ hookEventName = 'PreToolUse'
131
+ permissionDecision = 'deny'
132
+ permissionDecisionReason = "FEATURE_FOLDER_ORDER_BLOCKED: cannot write plan.md before producing prerequisite documents. Missing in feature folder: $list. Invoke the prd-feature subagent to generate the missing file(s) before authoring plan.md."
133
+ }
130
134
  }
131
135
  }
132
136
 
@@ -143,6 +147,6 @@ catch {
143
147
  exit 1
144
148
  }
145
149
 
146
- $decision | ConvertTo-Json -Compress | Write-Output
150
+ $decision | ConvertTo-Json -Compress -Depth 5 | Write-Output
147
151
 
148
152
  exit 0