@danmoisan/drm-copilot-mcp 1.1.15 → 1.1.17
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 +639 -27
- package/package.json +1 -1
- package/resources/claude-customizations/.claude/agents/parallel-orchestrator.md +12 -6
- package/resources/claude-customizations/.claude/agents/powershell-typed-engineer.md +4 -5
- package/resources/claude-customizations/.claude/agents/python-typed-engineer.md +3 -3
- package/resources/claude-customizations/.claude/hooks/enforce-batch-budget-route.ps1 +139 -0
- package/resources/claude-customizations/.claude/hooks/enforce-epic-merge-gate-resolution.ps1 +221 -0
- package/resources/claude-customizations/.claude/hooks/enforce-epic-merge-gate.ps1 +42 -66
- package/resources/claude-customizations/.claude/hooks/enforce-epic-wave-barrier.ps1 +58 -10
- package/resources/claude-customizations/.claude/hooks/enforce-epic-worktree-removal-gate-resolution.ps1 +144 -0
- package/resources/claude-customizations/.claude/hooks/enforce-epic-worktree-removal-gate.ps1 +36 -47
- package/resources/claude-customizations/.claude/hooks/enforce-orchestration-preimplementation-gate-epic-scope.ps1 +179 -27
- package/resources/claude-customizations/.claude/hooks/enforce-orchestration-preimplementation-gate.ps1 +21 -38
- package/resources/claude-customizations/.claude/hooks/enforce-parallel-abandon-gate.ps1 +4 -4
- package/resources/claude-customizations/.claude/hooks/enforce-parallel-cohort-barrier.ps1 +58 -10
- package/resources/claude-customizations/.claude/hooks/enforce-parallel-drift-gate.ps1 +59 -9
- package/resources/claude-customizations/.claude/hooks/enforce-parallel-worktree-removal-gate.ps1 +114 -37
- package/resources/claude-customizations/.claude/hooks/enforce-powershell-batch-budget.ps1 +95 -66
- package/resources/claude-customizations/.claude/hooks/enforce-python-batch-budget.ps1 +99 -64
- package/resources/claude-customizations/.claude/lib/bash/abandon-parallel-item.sh +176 -0
- package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusConflict.psm1 +193 -14
- package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusGlob.psm1 +51 -28
- package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusScheduling.psm1 +5 -8
- package/resources/claude-customizations/.claude/lib/parallel-drift/Invoke-ParallelDriftDetection.ps1 +336 -0
- package/resources/claude-customizations/.claude/lib/parallel-drift/ParallelDrift.psm1 +467 -0
- package/resources/claude-customizations/.claude/lib/parallel-drift/ParallelDriftHalt.psm1 +388 -0
- package/resources/claude-customizations/.claude/lib/worktree-resolution/EpicScopeResolution.psm1 +53 -32
- package/resources/claude-customizations/.claude/lib/worktree-resolution/WorktreeItemResolution.psm1 +19 -4
- package/resources/claude-customizations/.claude/lib/worktree-resolution/WorktreeRunResolution.psm1 +497 -0
- package/resources/claude-customizations/.claude/rules/parallel-orchestration.md +21 -0
- package/resources/claude-customizations/.claude/rules/powershell.md +3 -3
- package/resources/claude-customizations/.claude/skills/epic-orchestrate/SKILL.md +2 -0
- package/resources/claude-customizations/.claude/skills/invoke-csharp-engineer/SKILL.md +4 -0
- package/resources/claude-customizations/.claude/skills/invoke-powershell-engineer/SKILL.md +7 -4
- package/resources/claude-customizations/.claude/skills/invoke-python-engineer/SKILL.md +6 -3
- package/resources/claude-customizations/.claude/skills/orchestrate/SKILL.md +3 -1
- package/resources/claude-customizations/.claude/skills/parallel-orchestrate/SKILL.md +32 -18
- package/resources/claude-customizations/.claude/skills/parallel-remove/SKILL.md +10 -10
- package/resources/claude-customizations/.claude/skills/powershell-change-budget-router/SKILL.md +6 -6
- package/resources/claude-customizations/.claude/skills/python-change-budget-router/SKILL.md +11 -35
- package/resources/claude-customizations/pack-manifests/core.json +8 -0
- package/resources/codex-and-agents-customizations/.agents/skills/invoke-powershell-engineer/SKILL.md +1 -2
- package/resources/codex-and-agents-customizations/.agents/skills/invoke-python-engineer/SKILL.md +2 -3
- package/resources/codex-and-agents-customizations/.agents/skills/powershell/SKILL.md +1 -2
- package/resources/codex-and-agents-customizations/.agents/skills/python-change-budget-router/SKILL.md +10 -34
- package/resources/codex-and-agents-customizations/.codex/agents/powershell-typed-engineer-c1.toml +2 -3
- package/resources/codex-and-agents-customizations/.codex/agents/powershell-typed-engineer-c2.toml +2 -3
- package/resources/codex-and-agents-customizations/.codex/agents/powershell-typed-engineer-c3-elevated.toml +2 -3
- package/resources/codex-and-agents-customizations/.codex/agents/powershell-typed-engineer-c3.toml +2 -3
- package/resources/codex-and-agents-customizations/.codex/agents/powershell-typed-engineer-c4.toml +2 -3
- package/resources/codex-and-agents-customizations/.codex/agents/powershell-typed-engineer.toml +2 -3
- package/resources/codex-and-agents-customizations/.codex/agents/python-orchestrator.toml +2 -2
- package/resources/codex-and-agents-customizations/.codex/agents/python-typed-engineer-c1.toml +3 -3
- package/resources/codex-and-agents-customizations/.codex/agents/python-typed-engineer-c2.toml +3 -3
- package/resources/codex-and-agents-customizations/.codex/agents/python-typed-engineer-c3-elevated.toml +3 -3
- package/resources/codex-and-agents-customizations/.codex/agents/python-typed-engineer-c3.toml +3 -3
- package/resources/codex-and-agents-customizations/.codex/agents/python-typed-engineer-c4.toml +3 -3
- package/resources/codex-and-agents-customizations/.codex/agents/python-typed-engineer.toml +3 -3
- package/resources/codex-and-agents-customizations/.codex/config.toml +1 -1
- package/resources/codex-and-agents-customizations/.codex/hooks/enforce-batch-budget-route.ps1 +139 -0
- package/resources/codex-and-agents-customizations/.codex/hooks/enforce-powershell-batch-budget.ps1 +165 -80
- package/resources/codex-and-agents-customizations/.codex/hooks/enforce-python-batch-budget.ps1 +169 -79
- package/resources/codex-and-agents-customizations/pack-manifests/core.json +1 -0
- package/resources/customizations/.github/agents/powershell-orchestrator.agent.md +7 -7
- package/resources/customizations/.github/agents/powershell-typed-engineer.agent.md +8 -10
- package/resources/customizations/.github/agents/python-orchestrator.agent.md +4 -4
- package/resources/customizations/.github/agents/python-typed-engineer.agent.md +7 -8
- package/resources/customizations/.github/prompts/orchestrate-powershell-work.prompt.md +2 -2
- package/resources/customizations/.github/prompts/orchestrate-python-work.prompt.md +3 -3
- package/resources/customizations/.github/skills/powershell-change-budget-router/SKILL.md +3 -3
- package/resources/powershell/PoshQC/settings/pester.runsettings.psd1 +12 -0
|
@@ -1,18 +1,35 @@
|
|
|
1
1
|
<#
|
|
2
2
|
.SYNOPSIS
|
|
3
|
-
Pre-tool-use hook that
|
|
3
|
+
Pre-tool-use hook that routes PowerShell changes of more than three production files to the orchestrated large path.
|
|
4
4
|
|
|
5
5
|
.DESCRIPTION
|
|
6
6
|
This script is invoked by the Claude Code PreToolUse hook before any Write or Edit
|
|
7
7
|
operation. When the target file is a PowerShell source file (.ps1, .psm1, .psd1),
|
|
8
|
-
it
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
it decides whether the change may proceed in direct mode.
|
|
9
|
+
|
|
10
|
+
Direct mode counts distinct production PowerShell paths per Claude Code session.
|
|
11
|
+
The running set is persisted under .claude/state/powershell-batch-budget.<session_id>.json,
|
|
12
|
+
and repeated edits to the same file are counted once. The 4th distinct production
|
|
13
|
+
path is denied with a POWERSHELL_LARGE_PATH_REQUIRED reason that instructs the
|
|
14
|
+
caller to route the change through /orchestrate.
|
|
15
|
+
|
|
16
|
+
The orchestrated large path is detected from
|
|
17
|
+
<root>/artifacts/orchestration/orchestrator-state.json. The selected route is the
|
|
18
|
+
route_id value when that key is present, otherwise the path_selected value, and it
|
|
19
|
+
is usable only as a non-blank string. When the selected route is large,
|
|
20
|
+
remediation, or preparation and the checkpoint is not terminal (next_step is not
|
|
21
|
+
complete and completed_steps does not contain S12_complete), no path is denied for
|
|
22
|
+
count and no state is written. Every other checkpoint outcome, including an absent,
|
|
23
|
+
unreadable, or malformed checkpoint, enforces direct mode.
|
|
24
|
+
|
|
25
|
+
Test files are never counted. They are those matching:
|
|
26
|
+
- tests/**/*.ps1
|
|
27
|
+
- *.Tests.ps1
|
|
12
28
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
29
|
+
All other .ps1/.psm1/.psd1 files are production files. Non-PowerShell paths pass
|
|
30
|
+
through. The threshold of three production files is a routing constant and is not
|
|
31
|
+
configurable at runtime. Legacy prodCap, testCap, and testFiles keys in a persisted
|
|
32
|
+
state file are ignored when the state is loaded.
|
|
16
33
|
|
|
17
34
|
The session id is resolved from the first non-empty of: the CLAUDE_SESSION_ID
|
|
18
35
|
environment variable; the contents of <root>/.claude/state/current-session-id;
|
|
@@ -27,22 +44,13 @@
|
|
|
27
44
|
dropped when state is rehydrated, so a state file carried between worktrees
|
|
28
45
|
cannot spend this worktree's budget.
|
|
29
46
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
All other .ps1/.psm1/.psd1 files are treated as production files. Non-PowerShell
|
|
35
|
-
paths pass through.
|
|
36
|
-
|
|
37
|
-
The cap may be overridden per session by setting the environment variable
|
|
38
|
-
CLAUDE_POWERSHELL_BUDGET_PROD or CLAUDE_POWERSHELL_BUDGET_TEST to a positive integer
|
|
39
|
-
before the session starts, or by writing {"prodCap": N, "testCap": M} into the
|
|
40
|
-
state file.
|
|
47
|
+
When a production path is denied, the script emits a PreToolUse JSON response with
|
|
48
|
+
hookSpecificOutput.permissionDecision = 'deny' and exits 0. Files already counted
|
|
49
|
+
are always allowed through.
|
|
41
50
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
batch. Files already counted are always allowed through.
|
|
51
|
+
Known limitation: a stale non-terminal large-path checkpoint left at the root
|
|
52
|
+
exempts a later direct-mode session at that root. Orchestrator checkpoint hygiene
|
|
53
|
+
(issue #673) moves foreign checkpoints aside before a new run.
|
|
46
54
|
|
|
47
55
|
.NOTES
|
|
48
56
|
Compatible with PowerShell 7+.
|
|
@@ -52,6 +60,7 @@ param()
|
|
|
52
60
|
|
|
53
61
|
|
|
54
62
|
Import-Module (Join-Path $PSScriptRoot '../lib/hook-payload/HookPayload.psm1') -Force
|
|
63
|
+
. (Join-Path $PSScriptRoot 'enforce-batch-budget-route.ps1')
|
|
55
64
|
|
|
56
65
|
function Test-PowerShellBatchBudgetPathInRoot {
|
|
57
66
|
<#
|
|
@@ -174,25 +183,24 @@ function Get-PowerShellBatchBudgetSessionId {
|
|
|
174
183
|
}
|
|
175
184
|
|
|
176
185
|
function Get-PowerShellBatchBudgetState {
|
|
186
|
+
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'TestCap', Justification = 'Accepted and ignored for callers written against the removed test-file cap.')]
|
|
177
187
|
[CmdletBinding()]
|
|
178
188
|
[OutputType([System.Collections.Specialized.OrderedDictionary])]
|
|
179
189
|
param(
|
|
180
190
|
[Parameter(Mandatory)]
|
|
181
191
|
[int] $ProdCap,
|
|
182
192
|
|
|
183
|
-
[
|
|
184
|
-
[int] $TestCap
|
|
193
|
+
[int] $TestCap = 0
|
|
185
194
|
)
|
|
186
195
|
|
|
187
196
|
[ordered]@{
|
|
188
197
|
prodCap = $ProdCap
|
|
189
|
-
testCap = $TestCap
|
|
190
198
|
prodFiles = @()
|
|
191
|
-
testFiles = @()
|
|
192
199
|
}
|
|
193
200
|
}
|
|
194
201
|
|
|
195
202
|
function ConvertTo-PowerShellBatchBudgetState {
|
|
203
|
+
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'TestCap', Justification = 'Accepted and ignored for callers written against the removed test-file cap.')]
|
|
196
204
|
[CmdletBinding()]
|
|
197
205
|
[OutputType([System.Collections.Specialized.OrderedDictionary])]
|
|
198
206
|
param(
|
|
@@ -202,17 +210,16 @@ function ConvertTo-PowerShellBatchBudgetState {
|
|
|
202
210
|
[Parameter(Mandatory)]
|
|
203
211
|
[int] $ProdCap,
|
|
204
212
|
|
|
205
|
-
[
|
|
206
|
-
[int] $TestCap,
|
|
213
|
+
[int] $TestCap = 0,
|
|
207
214
|
|
|
208
215
|
[AllowNull()]
|
|
209
216
|
[AllowEmptyString()]
|
|
210
217
|
[string] $Root = (Split-Path (Split-Path $PSScriptRoot -Parent) -Parent)
|
|
211
218
|
)
|
|
212
219
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
220
|
+
# Only prodFiles is carried over. Persisted prodCap, testCap, and testFiles keys
|
|
221
|
+
# come from the removed test-file cap and runtime overrides and are ignored.
|
|
222
|
+
$state = Get-PowerShellBatchBudgetState -ProdCap $ProdCap
|
|
216
223
|
|
|
217
224
|
# Persisted entries that resolve outside this root belong to another worktree
|
|
218
225
|
# and are dropped, so a state file carried across worktrees cannot spend this
|
|
@@ -220,9 +227,6 @@ function ConvertTo-PowerShellBatchBudgetState {
|
|
|
220
227
|
if ($null -ne $InputObject.prodFiles) {
|
|
221
228
|
$state.prodFiles = @(@($InputObject.prodFiles) | Where-Object { Test-PowerShellBatchBudgetPathInRoot -Path $_ -Root $Root })
|
|
222
229
|
}
|
|
223
|
-
if ($null -ne $InputObject.testFiles) {
|
|
224
|
-
$state.testFiles = @(@($InputObject.testFiles) | Where-Object { Test-PowerShellBatchBudgetPathInRoot -Path $_ -Root $Root })
|
|
225
|
-
}
|
|
226
230
|
|
|
227
231
|
return $state
|
|
228
232
|
}
|
|
@@ -261,12 +265,17 @@ function Invoke-PowerShellBatchBudgetDecision {
|
|
|
261
265
|
[Parameter(Mandatory)]
|
|
262
266
|
[System.Collections.IDictionary] $State,
|
|
263
267
|
|
|
264
|
-
[
|
|
265
|
-
[string] $StateFile,
|
|
268
|
+
[AllowEmptyString()]
|
|
269
|
+
[string] $StateFile = '',
|
|
266
270
|
|
|
267
271
|
[AllowNull()]
|
|
268
272
|
[AllowEmptyString()]
|
|
269
|
-
[string] $Root = (Split-Path (Split-Path $PSScriptRoot -Parent) -Parent)
|
|
273
|
+
[string] $Root = (Split-Path (Split-Path $PSScriptRoot -Parent) -Parent),
|
|
274
|
+
|
|
275
|
+
[switch] $LargePathRoute,
|
|
276
|
+
|
|
277
|
+
[AllowEmptyString()]
|
|
278
|
+
[string] $ObservedRoute = ''
|
|
270
279
|
)
|
|
271
280
|
|
|
272
281
|
$normalized = $FilePath -replace '\\', '/'
|
|
@@ -281,27 +290,32 @@ function Invoke-PowerShellBatchBudgetDecision {
|
|
|
281
290
|
return [ordered]@{ hookSpecificOutput = [ordered]@{ hookEventName = 'PreToolUse'; permissionDecision = 'allow' }; state = $State; shouldWriteState = $false }
|
|
282
291
|
}
|
|
283
292
|
|
|
293
|
+
# The orchestrated large path has no production-file cap, so nothing is counted.
|
|
294
|
+
if ($LargePathRoute) {
|
|
295
|
+
return [ordered]@{ hookSpecificOutput = [ordered]@{ hookEventName = 'PreToolUse'; permissionDecision = 'allow' }; state = $State; shouldWriteState = $false }
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
# Test files never count toward the routing threshold.
|
|
284
299
|
$isTestFile = ($normalized -match '(^|/)tests/.*\.ps1$') -or ($normalized -match '\.Tests\.ps1$')
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
300
|
+
if ($isTestFile) {
|
|
301
|
+
return [ordered]@{ hookSpecificOutput = [ordered]@{ hookEventName = 'PreToolUse'; permissionDecision = 'allow' }; state = $State; shouldWriteState = $false }
|
|
302
|
+
}
|
|
288
303
|
|
|
289
|
-
|
|
304
|
+
$countedFiles = @($State.prodFiles)
|
|
305
|
+
if ($countedFiles -contains $normalized) {
|
|
290
306
|
return [ordered]@{ hookSpecificOutput = [ordered]@{ hookEventName = 'PreToolUse'; permissionDecision = 'allow' }; state = $State; shouldWriteState = $false }
|
|
291
307
|
}
|
|
292
308
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
$
|
|
296
|
-
$
|
|
309
|
+
$cap = [int]$State.prodCap
|
|
310
|
+
if ($countedFiles.Count -ge $cap) {
|
|
311
|
+
Write-Verbose "Denying production PowerShell path '$normalized' in direct mode; counted paths are recorded in '$StateFile'."
|
|
312
|
+
$counted = $countedFiles -join ', '
|
|
313
|
+
$route = if ([string]::IsNullOrWhiteSpace($ObservedRoute)) { 'none' } else { $ObservedRoute }
|
|
314
|
+
$reason = "POWERSHELL_LARGE_PATH_REQUIRED: this change touches more than $cap production PowerShell files (already counted: $counted; requested: $normalized). A change of this size belongs on the orchestrated large path, which has no production-file cap. Route the change through /orchestrate. Checkpoint route observed: $route."
|
|
297
315
|
return Get-PowerShellBatchBudgetBlockDecision -Reason $reason -State $State
|
|
298
316
|
}
|
|
299
317
|
|
|
300
|
-
|
|
301
|
-
$State.testFiles = @($State.testFiles) + @($normalized)
|
|
302
|
-
} else {
|
|
303
|
-
$State.prodFiles = @($State.prodFiles) + @($normalized)
|
|
304
|
-
}
|
|
318
|
+
$State.prodFiles = $countedFiles + @($normalized)
|
|
305
319
|
|
|
306
320
|
return [ordered]@{ hookSpecificOutput = [ordered]@{ hookEventName = 'PreToolUse'; permissionDecision = 'allow' }; state = $State; shouldWriteState = $true }
|
|
307
321
|
}
|
|
@@ -314,7 +328,6 @@ function Invoke-PowerShellBatchBudgetHook {
|
|
|
314
328
|
[string] $SessionId = '',
|
|
315
329
|
[string] $Root = (Split-Path (Split-Path $PSScriptRoot -Parent) -Parent),
|
|
316
330
|
[int] $ProdCap = 3,
|
|
317
|
-
[int] $TestCap = 3,
|
|
318
331
|
[scriptblock] $ReadSessionIdFile = {
|
|
319
332
|
param([string] $Path)
|
|
320
333
|
if (Test-Path -LiteralPath $Path -PathType Leaf) {
|
|
@@ -322,6 +335,13 @@ function Invoke-PowerShellBatchBudgetHook {
|
|
|
322
335
|
}
|
|
323
336
|
return ''
|
|
324
337
|
},
|
|
338
|
+
[scriptblock] $ReadCheckpoint = {
|
|
339
|
+
param([string] $Path)
|
|
340
|
+
if (Test-Path -LiteralPath $Path -PathType Leaf) {
|
|
341
|
+
return (Get-Content -LiteralPath $Path -Raw)
|
|
342
|
+
}
|
|
343
|
+
return ''
|
|
344
|
+
},
|
|
325
345
|
[scriptblock] $TestPathExists = { param([string] $Path) Test-Path -Path $Path },
|
|
326
346
|
[scriptblock] $EnsureDirectory = { param([string] $Path) New-Item -ItemType Directory -Path $Path -Force | Out-Null },
|
|
327
347
|
[scriptblock] $ReadState = { param([string] $Path) Get-Content -Path $Path -Raw },
|
|
@@ -359,23 +379,41 @@ function Invoke-PowerShellBatchBudgetHook {
|
|
|
359
379
|
-SessionIdFilePath (Join-Path -Path $stateDir -ChildPath 'current-session-id') `
|
|
360
380
|
-ReadSessionIdFile $ReadSessionIdFile
|
|
361
381
|
|
|
382
|
+
$stateFile = Join-Path -Path $stateDir -ChildPath ("powershell-batch-budget.$resolvedSessionId.json")
|
|
383
|
+
|
|
384
|
+
# The checkpoint is read before any state operation, so the large path neither
|
|
385
|
+
# creates the state directory nor reads or writes the state file.
|
|
386
|
+
$checkpointPath = Join-Path -Path $Root -ChildPath 'artifacts/orchestration/orchestrator-state.json'
|
|
387
|
+
$checkpointText = ''
|
|
388
|
+
try {
|
|
389
|
+
$checkpointText = [string](& $ReadCheckpoint $checkpointPath)
|
|
390
|
+
} catch {
|
|
391
|
+
Write-Verbose "Treating unreadable orchestrator checkpoint '$checkpointPath' as direct mode: $($_.Exception.Message)"
|
|
392
|
+
$checkpointText = ''
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
$isLargePath = Test-BatchBudgetLargePathRoute -CheckpointText $checkpointText
|
|
396
|
+
$observedRoute = Get-BatchBudgetSelectedRoute -CheckpointText $checkpointText
|
|
397
|
+
if ($isLargePath) {
|
|
398
|
+
return Invoke-PowerShellBatchBudgetDecision -FilePath $filePath -State (Get-PowerShellBatchBudgetState -ProdCap $ProdCap) -StateFile $stateFile -Root $Root -LargePathRoute
|
|
399
|
+
}
|
|
400
|
+
|
|
362
401
|
if (-not (& $TestPathExists $stateDir)) {
|
|
363
402
|
& $EnsureDirectory $stateDir
|
|
364
403
|
}
|
|
365
404
|
|
|
366
|
-
$
|
|
367
|
-
$state = Get-PowerShellBatchBudgetState -ProdCap $ProdCap -TestCap $TestCap
|
|
405
|
+
$state = Get-PowerShellBatchBudgetState -ProdCap $ProdCap
|
|
368
406
|
|
|
369
407
|
if (& $TestPathExists $stateFile) {
|
|
370
408
|
try {
|
|
371
409
|
$loaded = & $ReadState $stateFile | ConvertFrom-Json -ErrorAction Stop
|
|
372
|
-
$state = ConvertTo-PowerShellBatchBudgetState -InputObject $loaded -ProdCap $ProdCap -
|
|
410
|
+
$state = ConvertTo-PowerShellBatchBudgetState -InputObject $loaded -ProdCap $ProdCap -Root $Root
|
|
373
411
|
} catch {
|
|
374
412
|
Write-Verbose "Ignoring unreadable PowerShell batch-budget state file '$stateFile': $($_.Exception.Message)"
|
|
375
413
|
}
|
|
376
414
|
}
|
|
377
415
|
|
|
378
|
-
$decision = Invoke-PowerShellBatchBudgetDecision -FilePath $filePath -State $state -StateFile $stateFile -Root $Root
|
|
416
|
+
$decision = Invoke-PowerShellBatchBudgetDecision -FilePath $filePath -State $state -StateFile $stateFile -Root $Root -ObservedRoute $observedRoute
|
|
379
417
|
if ($decision.shouldWriteState) {
|
|
380
418
|
try {
|
|
381
419
|
& $WriteState $stateFile $decision.state
|
|
@@ -423,16 +461,7 @@ function Invoke-PowerShellBatchBudgetEntryPoint {
|
|
|
423
461
|
# worktree-derived identifier.
|
|
424
462
|
$sessionId = [string]$env:CLAUDE_SESSION_ID
|
|
425
463
|
|
|
426
|
-
$
|
|
427
|
-
$testCap = 3
|
|
428
|
-
if ($env:CLAUDE_POWERSHELL_BUDGET_PROD -match '^\d+$') {
|
|
429
|
-
$prodCap = [int]$env:CLAUDE_POWERSHELL_BUDGET_PROD
|
|
430
|
-
}
|
|
431
|
-
if ($env:CLAUDE_POWERSHELL_BUDGET_TEST -match '^\d+$') {
|
|
432
|
-
$testCap = [int]$env:CLAUDE_POWERSHELL_BUDGET_TEST
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
$decision = Invoke-PowerShellBatchBudgetHook -ToolInputRaw $ToolInputRaw -SessionId $sessionId -ProdCap $prodCap -TestCap $testCap
|
|
464
|
+
$decision = Invoke-PowerShellBatchBudgetHook -ToolInputRaw $ToolInputRaw -SessionId $sessionId
|
|
436
465
|
if ($decision.hookSpecificOutput.permissionDecision -eq 'deny') {
|
|
437
466
|
$decision.Remove('state')
|
|
438
467
|
$decision | ConvertTo-Json -Compress -Depth 5 | Write-Output
|
|
@@ -1,17 +1,35 @@
|
|
|
1
1
|
<#
|
|
2
2
|
.SYNOPSIS
|
|
3
|
-
Pre-tool-use hook that
|
|
3
|
+
Pre-tool-use hook that routes Python changes of more than three production files to the orchestrated large path.
|
|
4
4
|
|
|
5
5
|
.DESCRIPTION
|
|
6
6
|
This script is invoked by the Claude Code PreToolUse hook before any Write or Edit
|
|
7
|
-
operation. When the target file is a Python file, it
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
operation. When the target file is a Python file (.py), it decides whether the
|
|
8
|
+
change may proceed in direct mode.
|
|
9
|
+
|
|
10
|
+
Direct mode counts distinct production Python paths per Claude Code session. The
|
|
11
|
+
running set is persisted under .claude/state/python-batch-budget.<session_id>.json,
|
|
12
|
+
and repeated edits to the same file are counted once. The 4th distinct production
|
|
13
|
+
path is denied with a PYTHON_LARGE_PATH_REQUIRED reason that instructs the caller
|
|
14
|
+
to route the change through /orchestrate.
|
|
15
|
+
|
|
16
|
+
The orchestrated large path is detected from
|
|
17
|
+
<root>/artifacts/orchestration/orchestrator-state.json. The selected route is the
|
|
18
|
+
route_id value when that key is present, otherwise the path_selected value, and it
|
|
19
|
+
is usable only as a non-blank string. When the selected route is large,
|
|
20
|
+
remediation, or preparation and the checkpoint is not terminal (next_step is not
|
|
21
|
+
complete and completed_steps does not contain S12_complete), no path is denied for
|
|
22
|
+
count and no state is read or written. Every other checkpoint outcome, including
|
|
23
|
+
an absent, unreadable, or malformed checkpoint, enforces direct mode.
|
|
24
|
+
|
|
25
|
+
Test files are never counted. They are those matching:
|
|
26
|
+
- tests/**/*.py
|
|
27
|
+
- test_*.py
|
|
11
28
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
29
|
+
All other .py files are production files. Non-Python paths pass through. The
|
|
30
|
+
threshold of three production files is a routing constant and is not configurable
|
|
31
|
+
at runtime. Legacy prodCap, testCap, and testFiles keys in a persisted state file
|
|
32
|
+
are ignored when the state is loaded.
|
|
15
33
|
|
|
16
34
|
The session id is resolved from the first non-empty of: the CLAUDE_SESSION_ID
|
|
17
35
|
environment variable; the contents of <root>/.claude/state/current-session-id;
|
|
@@ -26,20 +44,13 @@
|
|
|
26
44
|
dropped when state is rehydrated, so a state file carried between worktrees
|
|
27
45
|
cannot spend this worktree's budget.
|
|
28
46
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
All other .py files are treated as production files. Non-Python paths pass through.
|
|
34
|
-
|
|
35
|
-
The cap may be overridden per session by setting the environment variable
|
|
36
|
-
CLAUDE_PYTHON_BUDGET_PROD or CLAUDE_PYTHON_BUDGET_TEST to a positive integer before
|
|
37
|
-
the session starts, or by writing {"prodCap": N, "testCap": M} into the state file.
|
|
47
|
+
When a production path is denied, the script emits a PreToolUse JSON response with
|
|
48
|
+
hookSpecificOutput.permissionDecision = 'deny' and exits 0. Files already counted
|
|
49
|
+
are always allowed through.
|
|
38
50
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
batch. Files already counted are always allowed through.
|
|
51
|
+
Known limitation: a stale non-terminal large-path checkpoint left at the root
|
|
52
|
+
exempts a later direct-mode session at that root. Orchestrator checkpoint hygiene
|
|
53
|
+
(issue #673) moves foreign checkpoints aside before a new run.
|
|
43
54
|
|
|
44
55
|
.NOTES
|
|
45
56
|
Compatible with PowerShell 7+.
|
|
@@ -49,6 +60,7 @@ param()
|
|
|
49
60
|
|
|
50
61
|
|
|
51
62
|
Import-Module (Join-Path $PSScriptRoot '../lib/hook-payload/HookPayload.psm1') -Force
|
|
63
|
+
. (Join-Path $PSScriptRoot 'enforce-batch-budget-route.ps1')
|
|
52
64
|
|
|
53
65
|
function Test-PythonBatchBudgetPathInRoot {
|
|
54
66
|
<#
|
|
@@ -171,25 +183,24 @@ function Get-PythonBatchBudgetSessionId {
|
|
|
171
183
|
}
|
|
172
184
|
|
|
173
185
|
function Get-PythonBatchBudgetState {
|
|
186
|
+
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'TestCap', Justification = 'Accepted and ignored for callers written against the removed test-file cap.')]
|
|
174
187
|
[CmdletBinding()]
|
|
175
188
|
[OutputType([System.Collections.Specialized.OrderedDictionary])]
|
|
176
189
|
param(
|
|
177
190
|
[Parameter(Mandatory)]
|
|
178
191
|
[int] $ProdCap,
|
|
179
192
|
|
|
180
|
-
[
|
|
181
|
-
[int] $TestCap
|
|
193
|
+
[int] $TestCap = 0
|
|
182
194
|
)
|
|
183
195
|
|
|
184
196
|
[ordered]@{
|
|
185
197
|
prodCap = $ProdCap
|
|
186
|
-
testCap = $TestCap
|
|
187
198
|
prodFiles = @()
|
|
188
|
-
testFiles = @()
|
|
189
199
|
}
|
|
190
200
|
}
|
|
191
201
|
|
|
192
202
|
function ConvertTo-PythonBatchBudgetState {
|
|
203
|
+
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'TestCap', Justification = 'Accepted and ignored for callers written against the removed test-file cap.')]
|
|
193
204
|
[CmdletBinding()]
|
|
194
205
|
[OutputType([System.Collections.Specialized.OrderedDictionary])]
|
|
195
206
|
param(
|
|
@@ -199,17 +210,16 @@ function ConvertTo-PythonBatchBudgetState {
|
|
|
199
210
|
[Parameter(Mandatory)]
|
|
200
211
|
[int] $ProdCap,
|
|
201
212
|
|
|
202
|
-
[
|
|
203
|
-
[int] $TestCap,
|
|
213
|
+
[int] $TestCap = 0,
|
|
204
214
|
|
|
205
215
|
[AllowNull()]
|
|
206
216
|
[AllowEmptyString()]
|
|
207
217
|
[string] $Root = (Split-Path (Split-Path $PSScriptRoot -Parent) -Parent)
|
|
208
218
|
)
|
|
209
219
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
220
|
+
# Only prodFiles is carried over. Persisted prodCap, testCap, and testFiles keys
|
|
221
|
+
# come from the removed test-file cap and runtime overrides and are ignored.
|
|
222
|
+
$state = Get-PythonBatchBudgetState -ProdCap $ProdCap
|
|
213
223
|
|
|
214
224
|
# Persisted entries that resolve outside this root belong to another worktree
|
|
215
225
|
# and are dropped, so a state file carried across worktrees cannot spend this
|
|
@@ -217,9 +227,6 @@ function ConvertTo-PythonBatchBudgetState {
|
|
|
217
227
|
if ($null -ne $InputObject.prodFiles) {
|
|
218
228
|
$state.prodFiles = @(@($InputObject.prodFiles) | Where-Object { Test-PythonBatchBudgetPathInRoot -Path $_ -Root $Root })
|
|
219
229
|
}
|
|
220
|
-
if ($null -ne $InputObject.testFiles) {
|
|
221
|
-
$state.testFiles = @(@($InputObject.testFiles) | Where-Object { Test-PythonBatchBudgetPathInRoot -Path $_ -Root $Root })
|
|
222
|
-
}
|
|
223
230
|
|
|
224
231
|
return $state
|
|
225
232
|
}
|
|
@@ -249,6 +256,7 @@ function Get-PythonBatchBudgetBlockDecision {
|
|
|
249
256
|
}
|
|
250
257
|
|
|
251
258
|
function Invoke-PythonBatchBudgetDecision {
|
|
259
|
+
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'TestCap', Justification = 'Accepted and ignored for callers written against the removed test-file cap.')]
|
|
252
260
|
[CmdletBinding()]
|
|
253
261
|
[OutputType([System.Collections.Specialized.OrderedDictionary])]
|
|
254
262
|
param(
|
|
@@ -258,12 +266,19 @@ function Invoke-PythonBatchBudgetDecision {
|
|
|
258
266
|
[Parameter(Mandatory)]
|
|
259
267
|
[System.Collections.IDictionary] $State,
|
|
260
268
|
|
|
261
|
-
[
|
|
262
|
-
[string] $StateFile,
|
|
269
|
+
[AllowEmptyString()]
|
|
270
|
+
[string] $StateFile = '',
|
|
271
|
+
|
|
272
|
+
[int] $TestCap = 0,
|
|
263
273
|
|
|
264
274
|
[AllowNull()]
|
|
265
275
|
[AllowEmptyString()]
|
|
266
|
-
[string] $Root = (Split-Path (Split-Path $PSScriptRoot -Parent) -Parent)
|
|
276
|
+
[string] $Root = (Split-Path (Split-Path $PSScriptRoot -Parent) -Parent),
|
|
277
|
+
|
|
278
|
+
[switch] $LargePathRoute,
|
|
279
|
+
|
|
280
|
+
[AllowEmptyString()]
|
|
281
|
+
[string] $ObservedRoute = ''
|
|
267
282
|
)
|
|
268
283
|
|
|
269
284
|
$normalized = $FilePath -replace '\\', '/'
|
|
@@ -278,27 +293,32 @@ function Invoke-PythonBatchBudgetDecision {
|
|
|
278
293
|
return [ordered]@{ hookSpecificOutput = [ordered]@{ hookEventName = 'PreToolUse'; permissionDecision = 'allow' }; state = $State; shouldWriteState = $false }
|
|
279
294
|
}
|
|
280
295
|
|
|
296
|
+
# The orchestrated large path has no production-file cap, so nothing is counted.
|
|
297
|
+
if ($LargePathRoute) {
|
|
298
|
+
return [ordered]@{ hookSpecificOutput = [ordered]@{ hookEventName = 'PreToolUse'; permissionDecision = 'allow' }; state = $State; shouldWriteState = $false }
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
# Test files never count toward the routing threshold.
|
|
281
302
|
$isTestFile = ($normalized -match '(^|/)tests/.*\.py$') -or ($normalized -match '(^|/)test_[^/]+\.py$')
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
303
|
+
if ($isTestFile) {
|
|
304
|
+
return [ordered]@{ hookSpecificOutput = [ordered]@{ hookEventName = 'PreToolUse'; permissionDecision = 'allow' }; state = $State; shouldWriteState = $false }
|
|
305
|
+
}
|
|
285
306
|
|
|
286
|
-
|
|
307
|
+
$countedFiles = @($State.prodFiles)
|
|
308
|
+
if ($countedFiles -contains $normalized) {
|
|
287
309
|
return [ordered]@{ hookSpecificOutput = [ordered]@{ hookEventName = 'PreToolUse'; permissionDecision = 'allow' }; state = $State; shouldWriteState = $false }
|
|
288
310
|
}
|
|
289
311
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
$
|
|
293
|
-
$
|
|
312
|
+
$cap = [int]$State.prodCap
|
|
313
|
+
if ($countedFiles.Count -ge $cap) {
|
|
314
|
+
Write-Verbose "Denying production Python path '$normalized' in direct mode; counted paths are recorded in '$StateFile'."
|
|
315
|
+
$counted = $countedFiles -join ', '
|
|
316
|
+
$route = if ([string]::IsNullOrWhiteSpace($ObservedRoute)) { 'none' } else { $ObservedRoute }
|
|
317
|
+
$reason = "PYTHON_LARGE_PATH_REQUIRED: this change touches more than $cap production Python files (already counted: $counted; requested: $normalized). A change of this size belongs on the orchestrated large path, which has no production-file cap. Route the change through /orchestrate. Checkpoint route observed: $route."
|
|
294
318
|
return Get-PythonBatchBudgetBlockDecision -Reason $reason -State $State
|
|
295
319
|
}
|
|
296
320
|
|
|
297
|
-
|
|
298
|
-
$State.testFiles = @($State.testFiles) + @($normalized)
|
|
299
|
-
} else {
|
|
300
|
-
$State.prodFiles = @($State.prodFiles) + @($normalized)
|
|
301
|
-
}
|
|
321
|
+
$State.prodFiles = $countedFiles + @($normalized)
|
|
302
322
|
|
|
303
323
|
return [ordered]@{ hookSpecificOutput = [ordered]@{ hookEventName = 'PreToolUse'; permissionDecision = 'allow' }; state = $State; shouldWriteState = $true }
|
|
304
324
|
}
|
|
@@ -311,7 +331,6 @@ function Invoke-PythonBatchBudgetHook {
|
|
|
311
331
|
[string] $SessionId = '',
|
|
312
332
|
[string] $Root = (Split-Path (Split-Path $PSScriptRoot -Parent) -Parent),
|
|
313
333
|
[int] $ProdCap = 3,
|
|
314
|
-
[int] $TestCap = 3,
|
|
315
334
|
[scriptblock] $ReadSessionIdFile = {
|
|
316
335
|
param([string] $Path)
|
|
317
336
|
if (Test-Path -LiteralPath $Path -PathType Leaf) {
|
|
@@ -319,6 +338,13 @@ function Invoke-PythonBatchBudgetHook {
|
|
|
319
338
|
}
|
|
320
339
|
return ''
|
|
321
340
|
},
|
|
341
|
+
[scriptblock] $ReadCheckpoint = {
|
|
342
|
+
param([string] $Path)
|
|
343
|
+
if (Test-Path -LiteralPath $Path -PathType Leaf) {
|
|
344
|
+
return (Get-Content -LiteralPath $Path -Raw)
|
|
345
|
+
}
|
|
346
|
+
return ''
|
|
347
|
+
},
|
|
322
348
|
[scriptblock] $TestPathExists = { param([string] $Path) Test-Path -Path $Path },
|
|
323
349
|
[scriptblock] $EnsureDirectory = { param([string] $Path) New-Item -ItemType Directory -Path $Path -Force | Out-Null },
|
|
324
350
|
[scriptblock] $ReadState = { param([string] $Path) Get-Content -Path $Path -Raw },
|
|
@@ -356,23 +382,41 @@ function Invoke-PythonBatchBudgetHook {
|
|
|
356
382
|
-SessionIdFilePath (Join-Path -Path $stateDir -ChildPath 'current-session-id') `
|
|
357
383
|
-ReadSessionIdFile $ReadSessionIdFile
|
|
358
384
|
|
|
385
|
+
$stateFile = Join-Path -Path $stateDir -ChildPath ("python-batch-budget.$resolvedSessionId.json")
|
|
386
|
+
|
|
387
|
+
# The checkpoint is read before any state operation, so the large path neither
|
|
388
|
+
# creates the state directory nor reads or writes the state file.
|
|
389
|
+
$checkpointPath = Join-Path -Path $Root -ChildPath 'artifacts/orchestration/orchestrator-state.json'
|
|
390
|
+
$checkpointText = ''
|
|
391
|
+
try {
|
|
392
|
+
$checkpointText = [string](& $ReadCheckpoint $checkpointPath)
|
|
393
|
+
} catch {
|
|
394
|
+
Write-Verbose "Treating unreadable orchestrator checkpoint '$checkpointPath' as direct mode: $($_.Exception.Message)"
|
|
395
|
+
$checkpointText = ''
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
$isLargePath = Test-BatchBudgetLargePathRoute -CheckpointText $checkpointText
|
|
399
|
+
$observedRoute = Get-BatchBudgetSelectedRoute -CheckpointText $checkpointText
|
|
400
|
+
if ($isLargePath) {
|
|
401
|
+
return Invoke-PythonBatchBudgetDecision -FilePath $filePath -State (Get-PythonBatchBudgetState -ProdCap $ProdCap) -StateFile $stateFile -Root $Root -LargePathRoute
|
|
402
|
+
}
|
|
403
|
+
|
|
359
404
|
if (-not (& $TestPathExists $stateDir)) {
|
|
360
405
|
& $EnsureDirectory $stateDir
|
|
361
406
|
}
|
|
362
407
|
|
|
363
|
-
$
|
|
364
|
-
$state = Get-PythonBatchBudgetState -ProdCap $ProdCap -TestCap $TestCap
|
|
408
|
+
$state = Get-PythonBatchBudgetState -ProdCap $ProdCap
|
|
365
409
|
|
|
366
410
|
if (& $TestPathExists $stateFile) {
|
|
367
411
|
try {
|
|
368
412
|
$loaded = & $ReadState $stateFile | ConvertFrom-Json -ErrorAction Stop
|
|
369
|
-
$state = ConvertTo-PythonBatchBudgetState -InputObject $loaded -ProdCap $ProdCap -
|
|
413
|
+
$state = ConvertTo-PythonBatchBudgetState -InputObject $loaded -ProdCap $ProdCap -Root $Root
|
|
370
414
|
} catch {
|
|
371
415
|
Write-Verbose "Ignoring unreadable Python batch-budget state file '$stateFile': $($_.Exception.Message)"
|
|
372
416
|
}
|
|
373
417
|
}
|
|
374
418
|
|
|
375
|
-
$decision = Invoke-PythonBatchBudgetDecision -FilePath $filePath -State $state -StateFile $stateFile -Root $Root
|
|
419
|
+
$decision = Invoke-PythonBatchBudgetDecision -FilePath $filePath -State $state -StateFile $stateFile -Root $Root -ObservedRoute $observedRoute
|
|
376
420
|
if ($decision.shouldWriteState) {
|
|
377
421
|
try {
|
|
378
422
|
& $WriteState $stateFile $decision.state
|
|
@@ -420,16 +464,7 @@ function Invoke-PythonBatchBudgetEntryPoint {
|
|
|
420
464
|
# worktree-derived identifier.
|
|
421
465
|
$sessionId = [string]$env:CLAUDE_SESSION_ID
|
|
422
466
|
|
|
423
|
-
$
|
|
424
|
-
$testCap = 3
|
|
425
|
-
if ($env:CLAUDE_PYTHON_BUDGET_PROD -match '^\d+$') {
|
|
426
|
-
$prodCap = [int]$env:CLAUDE_PYTHON_BUDGET_PROD
|
|
427
|
-
}
|
|
428
|
-
if ($env:CLAUDE_PYTHON_BUDGET_TEST -match '^\d+$') {
|
|
429
|
-
$testCap = [int]$env:CLAUDE_PYTHON_BUDGET_TEST
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
$decision = Invoke-PythonBatchBudgetHook -ToolInputRaw $ToolInputRaw -SessionId $sessionId -ProdCap $prodCap -TestCap $testCap
|
|
467
|
+
$decision = Invoke-PythonBatchBudgetHook -ToolInputRaw $ToolInputRaw -SessionId $sessionId
|
|
433
468
|
if ($decision.hookSpecificOutput.permissionDecision -eq 'deny') {
|
|
434
469
|
$decision.Remove('state')
|
|
435
470
|
$decision | ConvertTo-Json -Compress -Depth 5 | Write-Output
|