@danmoisan/drm-copilot-mcp 1.1.16 → 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.
Files changed (58) hide show
  1. package/out/mcp-server.js +639 -27
  2. package/package.json +1 -1
  3. package/resources/claude-customizations/.claude/agents/parallel-orchestrator.md +12 -6
  4. package/resources/claude-customizations/.claude/agents/python-typed-engineer.md +3 -3
  5. package/resources/claude-customizations/.claude/hooks/{enforce-powershell-batch-budget-route.ps1 → enforce-batch-budget-route.ps1} +12 -10
  6. package/resources/claude-customizations/.claude/hooks/enforce-epic-merge-gate-resolution.ps1 +221 -0
  7. package/resources/claude-customizations/.claude/hooks/enforce-epic-merge-gate.ps1 +42 -66
  8. package/resources/claude-customizations/.claude/hooks/enforce-epic-wave-barrier.ps1 +58 -10
  9. package/resources/claude-customizations/.claude/hooks/enforce-epic-worktree-removal-gate-resolution.ps1 +144 -0
  10. package/resources/claude-customizations/.claude/hooks/enforce-epic-worktree-removal-gate.ps1 +36 -47
  11. package/resources/claude-customizations/.claude/hooks/enforce-orchestration-preimplementation-gate-epic-scope.ps1 +179 -27
  12. package/resources/claude-customizations/.claude/hooks/enforce-orchestration-preimplementation-gate.ps1 +21 -38
  13. package/resources/claude-customizations/.claude/hooks/enforce-parallel-abandon-gate.ps1 +4 -4
  14. package/resources/claude-customizations/.claude/hooks/enforce-parallel-cohort-barrier.ps1 +58 -10
  15. package/resources/claude-customizations/.claude/hooks/enforce-parallel-drift-gate.ps1 +59 -9
  16. package/resources/claude-customizations/.claude/hooks/enforce-parallel-worktree-removal-gate.ps1 +114 -37
  17. package/resources/claude-customizations/.claude/hooks/enforce-powershell-batch-budget.ps1 +3 -3
  18. package/resources/claude-customizations/.claude/hooks/enforce-python-batch-budget.ps1 +99 -64
  19. package/resources/claude-customizations/.claude/lib/bash/abandon-parallel-item.sh +176 -0
  20. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusConflict.psm1 +193 -14
  21. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusGlob.psm1 +51 -28
  22. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusScheduling.psm1 +5 -8
  23. package/resources/claude-customizations/.claude/lib/parallel-drift/Invoke-ParallelDriftDetection.ps1 +336 -0
  24. package/resources/claude-customizations/.claude/lib/parallel-drift/ParallelDrift.psm1 +467 -0
  25. package/resources/claude-customizations/.claude/lib/parallel-drift/ParallelDriftHalt.psm1 +388 -0
  26. package/resources/claude-customizations/.claude/lib/worktree-resolution/EpicScopeResolution.psm1 +53 -32
  27. package/resources/claude-customizations/.claude/lib/worktree-resolution/WorktreeItemResolution.psm1 +19 -4
  28. package/resources/claude-customizations/.claude/lib/worktree-resolution/WorktreeRunResolution.psm1 +497 -0
  29. package/resources/claude-customizations/.claude/rules/parallel-orchestration.md +21 -0
  30. package/resources/claude-customizations/.claude/skills/epic-orchestrate/SKILL.md +2 -0
  31. package/resources/claude-customizations/.claude/skills/invoke-csharp-engineer/SKILL.md +4 -0
  32. package/resources/claude-customizations/.claude/skills/invoke-powershell-engineer/SKILL.md +4 -0
  33. package/resources/claude-customizations/.claude/skills/invoke-python-engineer/SKILL.md +6 -3
  34. package/resources/claude-customizations/.claude/skills/orchestrate/SKILL.md +3 -1
  35. package/resources/claude-customizations/.claude/skills/parallel-orchestrate/SKILL.md +32 -18
  36. package/resources/claude-customizations/.claude/skills/parallel-remove/SKILL.md +10 -10
  37. package/resources/claude-customizations/.claude/skills/python-change-budget-router/SKILL.md +11 -35
  38. package/resources/claude-customizations/pack-manifests/core.json +8 -0
  39. package/resources/claude-customizations/pack-manifests/powershell.json +0 -1
  40. package/resources/codex-and-agents-customizations/.agents/skills/invoke-powershell-engineer/SKILL.md +0 -1
  41. package/resources/codex-and-agents-customizations/.agents/skills/invoke-python-engineer/SKILL.md +2 -3
  42. package/resources/codex-and-agents-customizations/.agents/skills/python-change-budget-router/SKILL.md +10 -34
  43. package/resources/codex-and-agents-customizations/.codex/agents/python-orchestrator.toml +2 -2
  44. package/resources/codex-and-agents-customizations/.codex/agents/python-typed-engineer-c1.toml +3 -3
  45. package/resources/codex-and-agents-customizations/.codex/agents/python-typed-engineer-c2.toml +3 -3
  46. package/resources/codex-and-agents-customizations/.codex/agents/python-typed-engineer-c3-elevated.toml +3 -3
  47. package/resources/codex-and-agents-customizations/.codex/agents/python-typed-engineer-c3.toml +3 -3
  48. package/resources/codex-and-agents-customizations/.codex/agents/python-typed-engineer-c4.toml +3 -3
  49. package/resources/codex-and-agents-customizations/.codex/agents/python-typed-engineer.toml +3 -3
  50. package/resources/codex-and-agents-customizations/.codex/config.toml +1 -1
  51. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-batch-budget-route.ps1 +139 -0
  52. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-powershell-batch-budget.ps1 +4 -124
  53. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-python-batch-budget.ps1 +169 -79
  54. package/resources/codex-and-agents-customizations/pack-manifests/core.json +1 -0
  55. package/resources/customizations/.github/agents/python-orchestrator.agent.md +4 -4
  56. package/resources/customizations/.github/agents/python-typed-engineer.agent.md +7 -8
  57. package/resources/customizations/.github/prompts/orchestrate-python-work.prompt.md +3 -3
  58. package/resources/powershell/PoshQC/settings/pester.runsettings.psd1 +12 -1
@@ -0,0 +1,139 @@
1
+ <#
2
+ .SYNOPSIS
3
+ Pure route helpers shared by the batch-budget hooks.
4
+
5
+ .DESCRIPTION
6
+ Defines the functions that read the selected route from orchestrator checkpoint
7
+ text and decide whether that checkpoint selects the uncapped orchestrated large
8
+ path. The file is dot-sourced by the PowerShell and Python batch-budget hooks and
9
+ has no entry point. The Claude and Codex runtime copies of this file are
10
+ byte-identical, and a parity test holds them identical. None of the functions
11
+ reads a file or an environment variable; the checkpoint text is supplied by the
12
+ caller.
13
+
14
+ .NOTES
15
+ Compatible with PowerShell 7+.
16
+ #>
17
+
18
+ function ConvertFrom-BatchBudgetCheckpoint {
19
+ <#
20
+ .SYNOPSIS
21
+ Parses orchestrator checkpoint text into a checkpoint object.
22
+ .DESCRIPTION
23
+ Returns the parsed object only when the text is a JSON object. Null or
24
+ whitespace text, text that is not parseable JSON, and JSON whose root is not
25
+ an object all return $null. Performs no file or environment access.
26
+ .PARAMETER CheckpointText
27
+ Raw text of the orchestrator checkpoint file.
28
+ #>
29
+ [CmdletBinding()]
30
+ [OutputType([psobject])]
31
+ param(
32
+ [AllowNull()]
33
+ [AllowEmptyString()]
34
+ [string] $CheckpointText
35
+ )
36
+
37
+ if ([string]::IsNullOrWhiteSpace($CheckpointText)) {
38
+ return $null
39
+ }
40
+
41
+ try {
42
+ $parsed = ConvertFrom-Json -InputObject $CheckpointText -ErrorAction Stop
43
+ } catch {
44
+ Write-Verbose "Ignoring unparseable orchestrator checkpoint text: $($_.Exception.Message)"
45
+ return $null
46
+ }
47
+
48
+ if ($parsed -is [System.Management.Automation.PSCustomObject]) {
49
+ return $parsed
50
+ }
51
+
52
+ return $null
53
+ }
54
+
55
+ function Get-BatchBudgetSelectedRoute {
56
+ <#
57
+ .SYNOPSIS
58
+ Returns the route selected by an orchestrator checkpoint.
59
+ .DESCRIPTION
60
+ Uses route_id when the checkpoint has that key, otherwise path_selected. The
61
+ value is returned only when it is a non-blank string; every other case,
62
+ including unparseable text, returns an empty string.
63
+ .PARAMETER CheckpointText
64
+ Raw text of the orchestrator checkpoint file.
65
+ #>
66
+ [CmdletBinding()]
67
+ [OutputType([string])]
68
+ param(
69
+ [AllowNull()]
70
+ [AllowEmptyString()]
71
+ [string] $CheckpointText
72
+ )
73
+
74
+ $checkpoint = ConvertFrom-BatchBudgetCheckpoint -CheckpointText $CheckpointText
75
+ if ($null -eq $checkpoint) {
76
+ return ''
77
+ }
78
+
79
+ $propertyNames = @($checkpoint.PSObject.Properties.Name)
80
+ $rawRoute = $null
81
+ if ($propertyNames -contains 'route_id') {
82
+ $rawRoute = $checkpoint.route_id
83
+ } elseif ($propertyNames -contains 'path_selected') {
84
+ $rawRoute = $checkpoint.path_selected
85
+ }
86
+
87
+ if ($rawRoute -is [string] -and -not [string]::IsNullOrWhiteSpace($rawRoute)) {
88
+ return $rawRoute
89
+ }
90
+
91
+ return ''
92
+ }
93
+
94
+ function Test-BatchBudgetLargePathRoute {
95
+ <#
96
+ .SYNOPSIS
97
+ Reports whether an orchestrator checkpoint selects the uncapped large path.
98
+ .DESCRIPTION
99
+ Returns $true only when the checkpoint parses to an object, its selected route
100
+ is large, remediation, or preparation (case-sensitive), next_step is not
101
+ complete, and completed_steps does not contain S12_complete. No input causes
102
+ an exception; any failure returns $false.
103
+ .PARAMETER CheckpointText
104
+ Raw text of the orchestrator checkpoint file.
105
+ #>
106
+ [CmdletBinding()]
107
+ [OutputType([bool])]
108
+ param(
109
+ [AllowNull()]
110
+ [AllowEmptyString()]
111
+ [string] $CheckpointText
112
+ )
113
+
114
+ try {
115
+ $checkpoint = ConvertFrom-BatchBudgetCheckpoint -CheckpointText $CheckpointText
116
+ if ($null -eq $checkpoint) {
117
+ return $false
118
+ }
119
+
120
+ $route = Get-BatchBudgetSelectedRoute -CheckpointText $CheckpointText
121
+ if (-not (@('large', 'remediation', 'preparation') -ccontains $route)) {
122
+ return $false
123
+ }
124
+
125
+ $propertyNames = @($checkpoint.PSObject.Properties.Name)
126
+ if ($propertyNames -contains 'next_step' -and $checkpoint.next_step -is [string] -and $checkpoint.next_step -ceq 'complete') {
127
+ return $false
128
+ }
129
+
130
+ if ($propertyNames -contains 'completed_steps' -and (@($checkpoint.completed_steps) -ccontains 'S12_complete')) {
131
+ return $false
132
+ }
133
+
134
+ return $true
135
+ } catch {
136
+ Write-Verbose "Treating the orchestrator checkpoint as direct mode: $($_.Exception.Message)"
137
+ return $false
138
+ }
139
+ }
@@ -50,128 +50,8 @@ param()
50
50
  # mapping for every tool name the ^(apply_patch|Edit|Write)$ matcher admits.
51
51
  . (Join-Path $PSScriptRoot 'codex-pretooluse-file-mapping.ps1')
52
52
 
53
- function ConvertFrom-PowerShellBatchBudgetCheckpoint {
54
- <#
55
- .SYNOPSIS
56
- Parses orchestrator checkpoint text into a checkpoint object.
57
- .DESCRIPTION
58
- Returns the parsed object only when the text is a JSON object. Null or
59
- whitespace text, text that is not parseable JSON, and JSON whose root is not
60
- an object all return $null. Performs no file or environment access.
61
- .PARAMETER CheckpointText
62
- Raw text of the orchestrator checkpoint file.
63
- #>
64
- [CmdletBinding()]
65
- [OutputType([psobject])]
66
- param(
67
- [AllowNull()]
68
- [AllowEmptyString()]
69
- [string] $CheckpointText
70
- )
71
-
72
- if ([string]::IsNullOrWhiteSpace($CheckpointText)) {
73
- return $null
74
- }
75
-
76
- try {
77
- $parsed = ConvertFrom-Json -InputObject $CheckpointText -ErrorAction Stop
78
- } catch {
79
- Write-Verbose "Ignoring unparseable orchestrator checkpoint text: $($_.Exception.Message)"
80
- return $null
81
- }
82
-
83
- if ($parsed -is [System.Management.Automation.PSCustomObject]) {
84
- return $parsed
85
- }
86
-
87
- return $null
88
- }
89
-
90
- function Get-PowerShellBatchBudgetSelectedRoute {
91
- <#
92
- .SYNOPSIS
93
- Returns the route selected by an orchestrator checkpoint.
94
- .DESCRIPTION
95
- Uses route_id when the checkpoint has that key, otherwise path_selected. The
96
- value is returned only when it is a non-blank string; every other case,
97
- including unparseable text, returns an empty string.
98
- .PARAMETER CheckpointText
99
- Raw text of the orchestrator checkpoint file.
100
- #>
101
- [CmdletBinding()]
102
- [OutputType([string])]
103
- param(
104
- [AllowNull()]
105
- [AllowEmptyString()]
106
- [string] $CheckpointText
107
- )
108
-
109
- $checkpoint = ConvertFrom-PowerShellBatchBudgetCheckpoint -CheckpointText $CheckpointText
110
- if ($null -eq $checkpoint) {
111
- return ''
112
- }
113
-
114
- $propertyNames = @($checkpoint.PSObject.Properties.Name)
115
- $rawRoute = $null
116
- if ($propertyNames -contains 'route_id') {
117
- $rawRoute = $checkpoint.route_id
118
- } elseif ($propertyNames -contains 'path_selected') {
119
- $rawRoute = $checkpoint.path_selected
120
- }
121
-
122
- if ($rawRoute -is [string] -and -not [string]::IsNullOrWhiteSpace($rawRoute)) {
123
- return $rawRoute
124
- }
125
-
126
- return ''
127
- }
128
-
129
- function Test-PowerShellBatchBudgetLargePathRoute {
130
- <#
131
- .SYNOPSIS
132
- Reports whether an orchestrator checkpoint selects the uncapped large path.
133
- .DESCRIPTION
134
- Returns $true only when the checkpoint parses to an object, its selected route
135
- is large, remediation, or preparation (case-sensitive), next_step is not
136
- complete, and completed_steps does not contain S12_complete. No input causes
137
- an exception; any failure returns $false.
138
- .PARAMETER CheckpointText
139
- Raw text of the orchestrator checkpoint file.
140
- #>
141
- [CmdletBinding()]
142
- [OutputType([bool])]
143
- param(
144
- [AllowNull()]
145
- [AllowEmptyString()]
146
- [string] $CheckpointText
147
- )
148
-
149
- try {
150
- $checkpoint = ConvertFrom-PowerShellBatchBudgetCheckpoint -CheckpointText $CheckpointText
151
- if ($null -eq $checkpoint) {
152
- return $false
153
- }
154
-
155
- $route = Get-PowerShellBatchBudgetSelectedRoute -CheckpointText $CheckpointText
156
- if (-not (@('large', 'remediation', 'preparation') -ccontains $route)) {
157
- return $false
158
- }
159
-
160
- $propertyNames = @($checkpoint.PSObject.Properties.Name)
161
- if ($propertyNames -contains 'next_step' -and $checkpoint.next_step -is [string] -and $checkpoint.next_step -ceq 'complete') {
162
- return $false
163
- }
164
-
165
- if ($propertyNames -contains 'completed_steps' -and (@($checkpoint.completed_steps) -ccontains 'S12_complete')) {
166
- return $false
167
- }
168
-
169
- return $true
170
- } catch {
171
- Write-Verbose "Treating the orchestrator checkpoint as direct mode: $($_.Exception.Message)"
172
- return $false
173
- }
174
- }
53
+ # Shared route helpers; this file is byte-identical to the Claude runtime copy.
54
+ . (Join-Path $PSScriptRoot 'enforce-batch-budget-route.ps1')
175
55
 
176
56
  function Get-PowerShellBatchBudgetState {
177
57
  [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'TestCap', Justification = 'Accepted and ignored for callers written against the removed test-file cap.')]
@@ -348,8 +228,8 @@ function Invoke-PowerShellBatchBudgetHook {
348
228
  $checkpointText = ''
349
229
  }
350
230
 
351
- $isLargePath = Test-PowerShellBatchBudgetLargePathRoute -CheckpointText $checkpointText
352
- $observedRoute = Get-PowerShellBatchBudgetSelectedRoute -CheckpointText $checkpointText
231
+ $isLargePath = Test-BatchBudgetLargePathRoute -CheckpointText $checkpointText
232
+ $observedRoute = Get-BatchBudgetSelectedRoute -CheckpointText $checkpointText
353
233
  if ($isLargePath) {
354
234
  return Invoke-PowerShellBatchBudgetDecision -FilePath $filePath -State (Get-PowerShellBatchBudgetState -ProdCap $ProdCap) -StateFile $stateFile -LargePathRoute
355
235
  }
@@ -1,32 +1,44 @@
1
1
 
2
2
  <#
3
3
  .SYNOPSIS
4
- Pre-tool-use hook that enforces the Python per-batch change budget.
4
+ Pre-tool-use hook that routes Python changes of more than three production files to the orchestrated large path.
5
5
 
6
6
  .DESCRIPTION
7
7
  This script is invoked by the Codex PreToolUse hook before any Write or Edit
8
- operation. When the target file is a Python file, it classifies the file as either
9
- production or test and checks the running count against the per-batch cap:
10
- - 3 production .py files per batch
11
- - 3 test .py files per batch
12
-
13
- A "batch" is scoped to the current Codex session. The running count is
14
- persisted under .codex/state/python-batch-budget.<session_id>.json. Only distinct
15
- file paths are counted; repeated edits to the same file consume one slot.
16
-
17
- Test files are those matching:
8
+ operation. When the target file is a Python file (.py), it decides whether the
9
+ change may proceed in direct mode.
10
+
11
+ Direct mode counts distinct production Python paths per Codex session. The running
12
+ set is persisted under .codex/state/python-batch-budget.<session_id>.json, and
13
+ repeated edits to the same file are counted once. The 4th distinct production path
14
+ is denied with a PYTHON_LARGE_PATH_REQUIRED reason that instructs the caller to
15
+ route the change through .codex/prompts/orchestrate-work.md.
16
+
17
+ The orchestrated large path is detected from
18
+ <root>/artifacts/orchestration/orchestrator-state.json. The selected route is the
19
+ route_id value when that key is present, otherwise the path_selected value, and it
20
+ is usable only as a non-blank string. When the selected route is large,
21
+ remediation, or preparation and the checkpoint is not terminal (next_step is not
22
+ complete and completed_steps does not contain S12_complete), no path is denied for
23
+ count and no state is read or written. Every other checkpoint outcome, including
24
+ an absent, unreadable, or malformed checkpoint, enforces direct mode.
25
+
26
+ Test files are never counted. They are those matching:
18
27
  - tests/**/*.py
19
28
  - test_*.py
20
29
 
21
- All other .py files are treated as production files. Non-Python paths pass through.
30
+ All other .py files are production files. Non-Python paths pass through. The
31
+ threshold of three production files is a routing constant and is not configurable
32
+ at runtime. Legacy prodCap, testCap, and testFiles keys in a persisted state file
33
+ are ignored when the state is loaded.
22
34
 
23
- An approved per-session override is recorded by writing
24
- {"prodCap": N, "testCap": M} into the Codex state file.
35
+ When a production path is denied, the script emits a PreToolUse JSON response with
36
+ hookSpecificOutput.permissionDecision = 'deny' and exits 0. Files already counted
37
+ are always allowed through.
25
38
 
26
- When the cap would be exceeded by a new file, the script emits a PreToolUse JSON
27
- response with hookSpecificOutput.permissionDecision = 'deny' and exits 0. The session
28
- must explicitly reset the counter by deleting the state file before starting a new
29
- batch. Files already counted are always allowed through.
39
+ Known limitation: a stale non-terminal large-path checkpoint left at the root
40
+ exempts a later direct-mode session at that root. Orchestrator checkpoint hygiene
41
+ (issue #673) moves foreign checkpoints aside before a new run.
30
42
 
31
43
  .NOTES
32
44
  Compatible with PowerShell 7+.
@@ -38,26 +50,28 @@ param()
38
50
  # mapping for every tool name the ^(apply_patch|Edit|Write)$ matcher admits.
39
51
  . (Join-Path $PSScriptRoot 'codex-pretooluse-file-mapping.ps1')
40
52
 
53
+ # Shared route helpers; this file is byte-identical to the Claude runtime copy.
54
+ . (Join-Path $PSScriptRoot 'enforce-batch-budget-route.ps1')
55
+
41
56
  function Get-PythonBatchBudgetState {
57
+ [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'TestCap', Justification = 'Accepted and ignored for callers written against the removed test-file cap.')]
42
58
  [CmdletBinding()]
43
59
  [OutputType([System.Collections.Specialized.OrderedDictionary])]
44
60
  param(
45
61
  [Parameter(Mandatory)]
46
62
  [int] $ProdCap,
47
63
 
48
- [Parameter(Mandatory)]
49
- [int] $TestCap
64
+ [int] $TestCap = 0
50
65
  )
51
66
 
52
67
  [ordered]@{
53
68
  prodCap = $ProdCap
54
- testCap = $TestCap
55
69
  prodFiles = @()
56
- testFiles = @()
57
70
  }
58
71
  }
59
72
 
60
73
  function ConvertTo-PythonBatchBudgetState {
74
+ [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'TestCap', Justification = 'Accepted and ignored for callers written against the removed test-file cap.')]
61
75
  [CmdletBinding()]
62
76
  [OutputType([System.Collections.Specialized.OrderedDictionary])]
63
77
  param(
@@ -67,15 +81,13 @@ function ConvertTo-PythonBatchBudgetState {
67
81
  [Parameter(Mandatory)]
68
82
  [int] $ProdCap,
69
83
 
70
- [Parameter(Mandatory)]
71
- [int] $TestCap
84
+ [int] $TestCap = 0
72
85
  )
73
86
 
74
- $state = Get-PythonBatchBudgetState -ProdCap $ProdCap -TestCap $TestCap
75
- if ($null -ne $InputObject.prodCap) { $state.prodCap = [int]$InputObject.prodCap }
76
- if ($null -ne $InputObject.testCap) { $state.testCap = [int]$InputObject.testCap }
87
+ # Only prodFiles is carried over. Persisted prodCap, testCap, and testFiles keys
88
+ # come from the removed test-file cap and recorded overrides and are ignored.
89
+ $state = Get-PythonBatchBudgetState -ProdCap $ProdCap
77
90
  if ($null -ne $InputObject.prodFiles) { $state.prodFiles = @($InputObject.prodFiles) }
78
- if ($null -ne $InputObject.testFiles) { $state.testFiles = @($InputObject.testFiles) }
79
91
 
80
92
  return $state
81
93
  }
@@ -105,6 +117,7 @@ function Get-PythonBatchBudgetBlockDecision {
105
117
  }
106
118
 
107
119
  function Invoke-PythonBatchBudgetDecision {
120
+ [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'TestCap', Justification = 'Accepted and ignored for callers written against the removed test-file cap.')]
108
121
  [CmdletBinding()]
109
122
  [OutputType([System.Collections.Specialized.OrderedDictionary])]
110
123
  param(
@@ -114,8 +127,15 @@ function Invoke-PythonBatchBudgetDecision {
114
127
  [Parameter(Mandatory)]
115
128
  [System.Collections.IDictionary] $State,
116
129
 
117
- [Parameter(Mandatory)]
118
- [string] $StateFile
130
+ [AllowEmptyString()]
131
+ [string] $StateFile = '',
132
+
133
+ [int] $TestCap = 0,
134
+
135
+ [switch] $LargePathRoute,
136
+
137
+ [AllowEmptyString()]
138
+ [string] $ObservedRoute = ''
119
139
  )
120
140
 
121
141
  $normalized = $FilePath -replace '\\', '/'
@@ -123,26 +143,32 @@ function Invoke-PythonBatchBudgetDecision {
123
143
  return [ordered]@{ hookSpecificOutput = [ordered]@{ hookEventName = 'PreToolUse'; permissionDecision = 'allow' }; state = $State; shouldWriteState = $false }
124
144
  }
125
145
 
146
+ # The orchestrated large path has no production-file cap, so nothing is counted.
147
+ if ($LargePathRoute) {
148
+ return [ordered]@{ hookSpecificOutput = [ordered]@{ hookEventName = 'PreToolUse'; permissionDecision = 'allow' }; state = $State; shouldWriteState = $false }
149
+ }
150
+
151
+ # Test files never count toward the routing threshold.
126
152
  $isTestFile = ($normalized -match '(^|/)tests/.*\.py$') -or ($normalized -match '(^|/)test_[^/]+\.py$')
127
- $targetList = if ($isTestFile) { @($State.testFiles) } else { @($State.prodFiles) }
128
- $cap = if ($isTestFile) { [int]$State.testCap } else { [int]$State.prodCap }
129
- $kind = if ($isTestFile) { 'test' } else { 'production' }
153
+ if ($isTestFile) {
154
+ return [ordered]@{ hookSpecificOutput = [ordered]@{ hookEventName = 'PreToolUse'; permissionDecision = 'allow' }; state = $State; shouldWriteState = $false }
155
+ }
130
156
 
131
- if ($targetList -contains $normalized) {
157
+ $countedFiles = @($State.prodFiles)
158
+ if ($countedFiles -contains $normalized) {
132
159
  return [ordered]@{ hookSpecificOutput = [ordered]@{ hookEventName = 'PreToolUse'; permissionDecision = 'allow' }; state = $State; shouldWriteState = $false }
133
160
  }
134
161
 
135
- if ($targetList.Count -ge $cap) {
136
- $currentFiles = ($targetList -join ', ')
137
- $reason = "Python per-batch budget exceeded: $kind file cap is $cap and is already full ($currentFiles). Requested new file: $normalized. Split the work into a new batch, record an approved cap in $StateFile, or reset the batch by deleting that state file."
162
+ $cap = [int]$State.prodCap
163
+ if ($countedFiles.Count -ge $cap) {
164
+ Write-Verbose "Denying production Python path '$normalized' in direct mode; counted paths are recorded in '$StateFile'."
165
+ $counted = $countedFiles -join ', '
166
+ $route = if ([string]::IsNullOrWhiteSpace($ObservedRoute)) { 'none' } else { $ObservedRoute }
167
+ $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 .codex/prompts/orchestrate-work.md. Checkpoint route observed: $route."
138
168
  return Get-PythonBatchBudgetBlockDecision -Reason $reason -State $State
139
169
  }
140
170
 
141
- if ($isTestFile) {
142
- $State.testFiles = @($State.testFiles) + @($normalized)
143
- } else {
144
- $State.prodFiles = @($State.prodFiles) + @($normalized)
145
- }
171
+ $State.prodFiles = $countedFiles + @($normalized)
146
172
 
147
173
  return [ordered]@{ hookSpecificOutput = [ordered]@{ hookEventName = 'PreToolUse'; permissionDecision = 'allow' }; state = $State; shouldWriteState = $true }
148
174
  }
@@ -155,7 +181,13 @@ function Invoke-PythonBatchBudgetHook {
155
181
  [string] $SessionId = 'default',
156
182
  [string] $Root = (Get-Location).Path,
157
183
  [int] $ProdCap = 3,
158
- [int] $TestCap = 3,
184
+ [scriptblock] $ReadCheckpoint = {
185
+ param([string] $Path)
186
+ if (Test-Path -LiteralPath $Path -PathType Leaf) {
187
+ return (Get-Content -LiteralPath $Path -Raw)
188
+ }
189
+ return ''
190
+ },
159
191
  [scriptblock] $TestPathExists = { param([string] $Path) Test-Path -Path $Path },
160
192
  [scriptblock] $EnsureDirectory = { param([string] $Path) New-Item -ItemType Directory -Path $Path -Force | Out-Null },
161
193
  [scriptblock] $ReadState = { param([string] $Path) Get-Content -Path $Path -Raw },
@@ -186,23 +218,41 @@ function Invoke-PythonBatchBudgetHook {
186
218
  }
187
219
 
188
220
  $stateDir = Join-Path -Path $Root -ChildPath '.codex/state'
221
+ $stateFile = Join-Path -Path $stateDir -ChildPath ("python-batch-budget.$SessionId.json")
222
+
223
+ # The checkpoint is read before any state operation, so the large path neither
224
+ # creates the state directory nor reads or writes the state file.
225
+ $checkpointPath = Join-Path -Path $Root -ChildPath 'artifacts/orchestration/orchestrator-state.json'
226
+ $checkpointText = ''
227
+ try {
228
+ $checkpointText = [string](& $ReadCheckpoint $checkpointPath)
229
+ } catch {
230
+ Write-Verbose "Treating unreadable orchestrator checkpoint '$checkpointPath' as direct mode: $($_.Exception.Message)"
231
+ $checkpointText = ''
232
+ }
233
+
234
+ $isLargePath = Test-BatchBudgetLargePathRoute -CheckpointText $checkpointText
235
+ $observedRoute = Get-BatchBudgetSelectedRoute -CheckpointText $checkpointText
236
+ if ($isLargePath) {
237
+ return Invoke-PythonBatchBudgetDecision -FilePath $filePath -State (Get-PythonBatchBudgetState -ProdCap $ProdCap) -StateFile $stateFile -LargePathRoute
238
+ }
239
+
189
240
  if (-not (& $TestPathExists $stateDir)) {
190
241
  & $EnsureDirectory $stateDir
191
242
  }
192
243
 
193
- $stateFile = Join-Path -Path $stateDir -ChildPath ("python-batch-budget.$SessionId.json")
194
- $state = Get-PythonBatchBudgetState -ProdCap $ProdCap -TestCap $TestCap
244
+ $state = Get-PythonBatchBudgetState -ProdCap $ProdCap
195
245
 
196
246
  if (& $TestPathExists $stateFile) {
197
247
  try {
198
248
  $loaded = & $ReadState $stateFile | ConvertFrom-Json -ErrorAction Stop
199
- $state = ConvertTo-PythonBatchBudgetState -InputObject $loaded -ProdCap $ProdCap -TestCap $TestCap
249
+ $state = ConvertTo-PythonBatchBudgetState -InputObject $loaded -ProdCap $ProdCap
200
250
  } catch {
201
251
  Write-Verbose "Ignoring unreadable Python batch-budget state file '$stateFile': $($_.Exception.Message)"
202
252
  }
203
253
  }
204
254
 
205
- $decision = Invoke-PythonBatchBudgetDecision -FilePath $filePath -State $state -StateFile $stateFile
255
+ $decision = Invoke-PythonBatchBudgetDecision -FilePath $filePath -State $state -StateFile $stateFile -ObservedRoute $observedRoute
206
256
  if ($decision.shouldWriteState) {
207
257
  try {
208
258
  & $WriteState $stateFile $decision.state
@@ -214,41 +264,81 @@ function Invoke-PythonBatchBudgetHook {
214
264
  return $decision
215
265
  }
216
266
 
217
- if ($MyInvocation.InvocationName -eq '.') {
218
- return
219
- }
267
+ function Invoke-PythonBatchBudgetCodexEntryPoint {
268
+ <#
269
+ .SYNOPSIS
270
+ Runs the Codex Python batch-budget decision and returns the process exit code.
271
+ .DESCRIPTION
272
+ Parses the Codex PreToolUse payload through the shared transport, collects both
273
+ sides of every mapped file edit, and evaluates each path with the Python hook.
274
+ The first deny is written to the output stream as compact JSON without its state
275
+ property, followed by exit code 0. When nothing is denied the function returns
276
+ 0 and writes nothing else. Any failure writes the error text to standard error
277
+ and returns 2. The function does not call exit.
278
+ .PARAMETER PayloadRaw
279
+ Raw PreToolUse payload text.
280
+ .PARAMETER RepositoryRoot
281
+ Root used to locate the Codex state directory and the orchestrator checkpoint.
282
+ .PARAMETER HookSeams
283
+ Optional seam overrides splatted into Invoke-PythonBatchBudgetHook.
284
+ #>
285
+ [CmdletBinding()]
286
+ [OutputType([int])]
287
+ param(
288
+ [Parameter(Mandatory)]
289
+ [AllowNull()]
290
+ [AllowEmptyString()]
291
+ [string] $PayloadRaw,
292
+
293
+ [Parameter(Mandatory)]
294
+ [string] $RepositoryRoot,
220
295
 
221
- try {
222
- # Transport and mapping come from the shared module. session_id is still
223
- # required because the batch counter is keyed by it.
224
- $payload = ConvertFrom-CodexPreToolUsePayload -PayloadRaw ([Console]::In.ReadToEnd()) -HookName 'enforce-python-batch-budget' -RequireSessionId
225
- $sessionId = ([string]$payload.session_id) -replace '[^A-Za-z0-9._-]', '_'
226
- $repositoryRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
227
- $prodCap = 3
228
- $testCap = 3
229
-
230
- # Both sides of a rename consume budget, matching the pre-fix path scan that
231
- # collected Add/Update/Delete targets and Move destinations alike. Ordering
232
- # and de-duplication are preserved. A well-formed payload that maps to no
233
- # file yields no paths, so no state is written and the hook allows silently.
234
- $budgetPaths = @(
235
- @(ConvertTo-CodexFileEditInput -Payload $payload) |
236
- ForEach-Object { $_.source_path; $_.file_path } |
237
- Where-Object { -not [string]::IsNullOrWhiteSpace($_) } |
238
- Select-Object -Unique
296
+ [hashtable] $HookSeams = @{}
239
297
  )
240
298
 
241
- foreach ($path in $budgetPaths) {
242
- $toolInputRaw = @{ file_path = $path } | ConvertTo-Json -Compress
243
- $decision = Invoke-PythonBatchBudgetHook -ToolInputRaw $toolInputRaw -SessionId $sessionId -Root $repositoryRoot -ProdCap $prodCap -TestCap $testCap
244
- if ($decision.hookSpecificOutput.permissionDecision -eq 'deny') {
245
- $decision.Remove('state')
246
- $decision | ConvertTo-Json -Compress -Depth 5 | Write-Output
247
- exit 0
299
+ try {
300
+ # Transport and mapping come from the shared module. session_id is still
301
+ # required because the direct-mode counter is keyed by it.
302
+ $payload = ConvertFrom-CodexPreToolUsePayload -PayloadRaw $PayloadRaw -HookName 'enforce-python-batch-budget' -RequireSessionId
303
+ $sessionId = ([string]$payload.session_id) -replace '[^A-Za-z0-9._-]', '_'
304
+
305
+ # Both sides of a rename are evaluated, matching the path scan that collected
306
+ # Add/Update/Delete targets and Move destinations alike. Ordering and
307
+ # de-duplication are preserved. A well-formed payload that maps to no file
308
+ # yields no paths, so no state is written and the hook allows silently.
309
+ $budgetPaths = @(
310
+ @(ConvertTo-CodexFileEditInput -Payload $payload) |
311
+ ForEach-Object { $_.source_path; $_.file_path } |
312
+ Where-Object { -not [string]::IsNullOrWhiteSpace($_) } |
313
+ Select-Object -Unique
314
+ )
315
+
316
+ foreach ($path in $budgetPaths) {
317
+ $toolInputRaw = @{ file_path = $path } | ConvertTo-Json -Compress
318
+ $decision = Invoke-PythonBatchBudgetHook -ToolInputRaw $toolInputRaw -SessionId $sessionId -Root $RepositoryRoot @HookSeams
319
+ if ($decision.hookSpecificOutput.permissionDecision -eq 'deny') {
320
+ $decision.Remove('state')
321
+ $decision | ConvertTo-Json -Compress -Depth 5 | Write-Output
322
+ return 0
323
+ }
248
324
  }
325
+
326
+ return 0
327
+ } catch {
328
+ [Console]::Error.WriteLine([string]$_)
329
+ return 2
249
330
  }
250
- exit 0
251
- } catch {
252
- [Console]::Error.WriteLine([string]$_)
253
- exit 2
254
331
  }
332
+
333
+ if ($MyInvocation.InvocationName -eq '.') {
334
+ return
335
+ }
336
+
337
+ # The entry point returns its [int] exit code as the last pipeline element and any
338
+ # deny JSON before it, so the JSON is written explicitly before the process exits.
339
+ $entryPointResult = @(Invoke-PythonBatchBudgetCodexEntryPoint -PayloadRaw ([Console]::In.ReadToEnd()) -RepositoryRoot (Split-Path (Split-Path $PSScriptRoot -Parent) -Parent))
340
+ if ($entryPointResult.Count -gt 1) {
341
+ $entryPointResult[0..($entryPointResult.Count - 2)] | Write-Output
342
+ }
343
+
344
+ exit ([int]$entryPointResult[-1])
@@ -48,6 +48,7 @@
48
48
  ".codex/hooks/validate-codex-subagent-routing.ps1",
49
49
  ".codex/hooks/check-powershell-test-purity.ps1",
50
50
  ".codex/hooks/check-python-test-purity.ps1",
51
+ ".codex/hooks/enforce-batch-budget-route.ps1",
51
52
  ".codex/hooks/enforce-checkpoint-monotonic.ps1",
52
53
  ".codex/hooks/enforce-completion-consistency.ps1",
53
54
  ".codex/hooks/enforce-completion-helpers.ps1",