@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.
- package/out/mcp-server.js +814 -194
- package/package.json +1 -1
- package/resources/claude-customizations/.claude/agents/commit-message.md +42 -0
- package/resources/claude-customizations/.claude/agents/epic-orchestrator.md +127 -0
- package/resources/claude-customizations/.claude/agents/human-exception-runbook.md +48 -0
- package/resources/claude-customizations/.claude/agents/orchestrator.md +67 -2
- package/resources/claude-customizations/.claude/agents/pr-author.md +37 -21
- package/resources/claude-customizations/.claude/hooks/check-powershell-test-purity.ps1 +103 -65
- package/resources/claude-customizations/.claude/hooks/check-python-test-purity.ps1 +12 -9
- package/resources/claude-customizations/.claude/hooks/enforce-checkpoint-monotonic.ps1 +24 -16
- package/resources/claude-customizations/.claude/hooks/enforce-completion-consistency.ps1 +19 -13
- package/resources/claude-customizations/.claude/hooks/enforce-epic-merge-gate.ps1 +304 -0
- package/resources/claude-customizations/.claude/hooks/enforce-epic-wave-barrier.ps1 +304 -0
- package/resources/claude-customizations/.claude/hooks/enforce-epic-worktree-removal-gate.ps1 +237 -0
- package/resources/claude-customizations/.claude/hooks/enforce-evidence-locations.ps1 +16 -12
- package/resources/claude-customizations/.claude/hooks/enforce-feature-folder-order.ps1 +14 -10
- package/resources/claude-customizations/.claude/hooks/enforce-orchestration-preimplementation-gate.ps1 +35 -8
- package/resources/claude-customizations/.claude/hooks/enforce-powershell-batch-budget.ps1 +17 -14
- package/resources/claude-customizations/.claude/hooks/enforce-pr-author-skill.epic-base-branch.ps1 +104 -0
- package/resources/claude-customizations/.claude/hooks/enforce-pr-author-skill.ps1 +268 -93
- package/resources/claude-customizations/.claude/hooks/enforce-prd-feature-before-planner.ps1 +19 -11
- package/resources/claude-customizations/.claude/hooks/enforce-promotion-mcp-only.ps1 +28 -6
- package/resources/claude-customizations/.claude/hooks/enforce-python-batch-budget.ps1 +17 -14
- package/resources/claude-customizations/.claude/hooks/validate-bash.ps1 +146 -36
- package/resources/claude-customizations/.claude/hooks/validate-orchestrator-output.ps1 +19 -7
- package/resources/claude-customizations/.claude/rules/orchestrator-state.md +36 -0
- package/resources/claude-customizations/.claude/settings.json +34 -1
- package/resources/claude-customizations/.claude/skills/epic-orchestrate/SKILL.md +252 -0
- package/resources/claude-customizations/.claude/skills/orchestrate/SKILL.md +42 -13
- package/resources/claude-customizations/.claude/skills/pr-author/SKILL.md +28 -0
- package/resources/claude-customizations/.claude/skills/remediation-handoff-atomic-planner/SKILL.md +22 -18
- package/resources/claude-customizations/pack-manifests/core.json +8 -0
- package/resources/codex-and-agents-customizations/.agents/skills/orchestrate/SKILL.md +6 -3
- package/resources/codex-and-agents-customizations/.agents/skills/remediation-handoff-atomic-planner/SKILL.md +1 -1
- package/resources/codex-and-agents-customizations/.agents-variants/csharp-legacy/skills/csharp/SKILL.md +65 -0
- package/resources/codex-and-agents-customizations/.agents-variants/csharp-legacy/skills/csharp-qa-gate/SKILL.md +79 -0
- package/resources/codex-and-agents-customizations/.agents-variants/csharp-legacy/skills/invoke-csharp-engineer/SKILL.md +66 -0
- package/resources/codex-and-agents-customizations/.codex/config.toml +1 -25
- package/resources/codex-and-agents-customizations/.codex/hooks/enforce-pr-author-skill.ps1 +257 -93
- package/resources/codex-and-agents-customizations/.codex-variants/csharp-legacy/agents/csharp-typed-engineer.toml +98 -0
- package/resources/codex-and-agents-customizations/pack-manifests/core.json +24 -0
- package/resources/codex-and-agents-customizations/pack-manifests/csharp-legacy.json +11 -0
- package/resources/codex-and-agents-customizations/pack-manifests/csharp-modern.json +10 -0
- package/resources/codex-and-agents-customizations/pack-manifests/powershell.json +15 -0
- package/resources/codex-and-agents-customizations/pack-manifests/python.json +15 -0
- package/resources/codex-and-agents-customizations/pack-manifests/typescript.json +9 -0
- package/resources/config/orchestration-routing.json +92 -0
- package/resources/customizations/.github/agents/pr-author.agent.md +24 -18
- package/resources/icon.png +0 -0
- package/resources/powershell/PoshQC/settings/pester.runsettings.psd1 +11 -0
- package/resources/codex-and-agents-customizations/.github/workflows/_validate-orchestrator-state.yml +0 -68
- 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
|
|
31
|
-
to stdout with
|
|
32
|
-
the reason. For allowed paths,
|
|
33
|
-
|
|
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
|
|
87
|
+
Constructs a deny-decision ordered dictionary for the supplied forbidden path.
|
|
87
88
|
.PARAMETER FilePath
|
|
88
|
-
The file path that triggered the
|
|
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
|
-
|
|
99
|
-
|
|
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]@{
|
|
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]@{
|
|
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]@{
|
|
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
|
|
14
|
-
response with
|
|
15
|
-
reason. All other paths pass through with
|
|
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]@{
|
|
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]@{
|
|
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]@{
|
|
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]@{
|
|
124
|
+
return [ordered]@{ hookSpecificOutput = [ordered]@{ hookEventName = 'PreToolUse'; permissionDecision = 'allow' } }
|
|
124
125
|
}
|
|
125
126
|
|
|
126
127
|
$list = ($missing -join ', ')
|
|
127
128
|
return [ordered]@{
|
|
128
|
-
|
|
129
|
-
|
|
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
|