@danmoisan/drm-copilot-mcp 1.0.19 → 1.0.21

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 (32) hide show
  1. package/out/mcp-server.js +71 -8
  2. package/package.json +1 -1
  3. package/resources/claude-customizations/.claude/agents/atomic-executor.md +2 -2
  4. package/resources/claude-customizations/.claude/hooks/validate-orchestrator-output.ps1 +14 -6
  5. package/resources/claude-customizations/.claude/lib/model-routing/ModelRouting.psm1 +34 -14
  6. package/resources/claude-customizations/.claude/lib/orchestrator-state/OrchestratorState.psm1 +30 -18
  7. package/resources/claude-customizations/.claude/rules/general-code-change.md +1 -1
  8. package/resources/claude-customizations/.claude/rules/general-unit-test.md +2 -2
  9. package/resources/claude-customizations/.claude/rules/typescript.md +5 -5
  10. package/resources/codex-and-agents-customizations/.agents/skills/general-code-change/SKILL.md +1 -1
  11. package/resources/codex-and-agents-customizations/.agents/skills/general-unit-test/SKILL.md +2 -2
  12. package/resources/codex-and-agents-customizations/.codex/agents/orchestrator-c1.toml +4 -0
  13. package/resources/codex-and-agents-customizations/.codex/agents/orchestrator-c2.toml +4 -0
  14. package/resources/codex-and-agents-customizations/.codex/agents/orchestrator-c3-elevated.toml +4 -0
  15. package/resources/codex-and-agents-customizations/.codex/agents/orchestrator-c3.toml +4 -0
  16. package/resources/codex-and-agents-customizations/.codex/agents/orchestrator-c4.toml +4 -0
  17. package/resources/codex-and-agents-customizations/.codex/agents/orchestrator.toml +4 -0
  18. package/resources/codex-and-agents-customizations/.codex/config.toml +1 -1
  19. package/resources/codex-and-agents-customizations/.codex/hooks/check-powershell-test-purity.ps1 +9 -65
  20. package/resources/codex-and-agents-customizations/.codex/hooks/check-python-test-purity.ps1 +9 -65
  21. package/resources/codex-and-agents-customizations/.codex/hooks/codex-pretooluse-file-mapping.ps1 +474 -0
  22. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-checkpoint-monotonic.ps1 +20 -101
  23. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-completion-consistency.ps1 +16 -3
  24. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-epic-child-worktree-binding.ps1 +14 -5
  25. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-epic-planning-only.ps1 +23 -5
  26. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-evidence-locations.ps1 +30 -52
  27. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-orchestration-preimplementation-gate.ps1 +28 -27
  28. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-powershell-batch-budget.ps1 +19 -49
  29. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-python-batch-budget.ps1 +19 -49
  30. package/resources/codex-and-agents-customizations/pack-manifests/core.json +1 -0
  31. package/resources/powershell/PoshQC/settings/pester.runsettings.psd1 +19 -0
  32. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-pr-author-skill.ps1 +0 -500
@@ -44,6 +44,15 @@
44
44
  [CmdletBinding()]
45
45
  param()
46
46
 
47
+ # Shared Codex PreToolUse transport: stdin payload parsing and tool_input-to-file
48
+ # mapping for every tool name the ^(apply_patch|Edit|Write)$ matcher admits.
49
+ . (Join-Path $PSScriptRoot 'codex-pretooluse-file-mapping.ps1')
50
+
51
+ # The only path this hook governs. On-disk reconstruction of an apply_patch
52
+ # Update is requested for this path alone, so a patch that merely happens to
53
+ # touch other files can never fail the hook.
54
+ $script:GovernedCheckpointPath = 'artifacts/orchestration/orchestrator-state.json'
55
+
47
56
  $script:CanonicalStepPrefixes = @(
48
57
  'S0_startup_checks',
49
58
  'S1_change_budget_estimation',
@@ -300,112 +309,22 @@ function Invoke-CheckpointMonotonicDecision {
300
309
  }
301
310
 
302
311
  # Guard allows dot-sourcing in tests without executing the entrypoint.
303
- function ConvertFrom-CodexCheckpointHookPayload {
304
- [CmdletBinding()]
305
- param([Parameter(Mandatory)][string] $PayloadRaw)
306
-
307
- if ([string]::IsNullOrWhiteSpace($PayloadRaw)) {
308
- throw 'enforce-checkpoint-monotonic hook input is empty.'
309
- }
310
- try {
311
- $payload = $PayloadRaw | ConvertFrom-Json -ErrorAction Stop
312
- } catch {
313
- throw "enforce-checkpoint-monotonic hook input is malformed JSON: $_"
314
- }
315
- if ($payload.PSObject.Properties.Name -notcontains 'tool_input' -or $null -eq $payload.tool_input) {
316
- throw 'enforce-checkpoint-monotonic hook input is missing tool_input.'
317
- }
318
- if ([string]$payload.hook_event_name -ne 'PreToolUse' -or [string]$payload.tool_name -ne 'apply_patch') {
319
- throw 'enforce-checkpoint-monotonic requires a PreToolUse apply_patch payload.'
320
- }
321
- return $payload
322
- }
323
-
324
- function ConvertTo-CodexApplyPatchCheckpointInput {
325
- [CmdletBinding()]
326
- [OutputType([object[]])]
327
- param([Parameter(Mandatory)] $Payload)
328
-
329
- if ($Payload.tool_input.PSObject.Properties.Name -contains 'file_path') {
330
- return , $Payload.tool_input
331
- }
332
- $command = [string]$Payload.tool_input.command
333
- if ([string]::IsNullOrWhiteSpace($command)) {
334
- throw 'enforce-checkpoint-monotonic cannot map tool_input to a file edit.'
335
- }
336
-
337
- $fileMatches = [regex]::Matches(
338
- $command,
339
- '(?ms)^\*\*\* (?<operation>Add|Update|Delete) File:\s*(?<path>.+?)\r?\n(?<body>.*?)(?=^\*\*\* (?:(?:Add|Update|Delete) File:|End Patch)\s*|\z)'
340
- )
341
- if ($fileMatches.Count -eq 0) {
342
- throw 'enforce-checkpoint-monotonic received an unrecognized apply_patch command.'
343
- }
344
-
345
- $inputs = [System.Collections.Generic.List[object]]::new()
346
- foreach ($match in $fileMatches) {
347
- $sourcePath = ([string]$match.Groups['path'].Value).Trim()
348
- $path = $sourcePath
349
- $operation = [string]$match.Groups['operation'].Value
350
- $body = ([string]$match.Groups['body'].Value) -replace '\r\n', "`n"
351
- $moveMatch = [regex]::Match($body, '(?m)^\*\*\* Move to:\s*(?<path>.+?)\s*$')
352
- if ($moveMatch.Success) {
353
- $path = ([string]$moveMatch.Groups['path'].Value).Trim()
354
- }
355
- if ($operation -eq 'Delete') {
356
- $content = ''
357
- } elseif ($operation -eq 'Add') {
358
- $content = (($body -split "`n") | ForEach-Object {
359
- if ($_.StartsWith('+') -and -not $_.StartsWith('+++')) {
360
- $_.Substring(1)
361
- }
362
- }) -join "`n"
363
- } else {
364
- if (-not (Test-Path -LiteralPath $sourcePath -PathType Leaf)) {
365
- throw "enforce-checkpoint-monotonic cannot read update source '$sourcePath'."
366
- }
367
- $content = (Get-Content -Raw -LiteralPath $sourcePath) -replace '\r\n', "`n"
368
- $hunks = @([regex]::Split($body, '(?m)^@@[^\r\n]*\r?\n') | Where-Object { $_ -match '\S' })
369
- foreach ($hunk in $hunks) {
370
- $oldLines = [System.Collections.Generic.List[string]]::new()
371
- $newLines = [System.Collections.Generic.List[string]]::new()
372
- foreach ($line in ($hunk -split "`n")) {
373
- if ($line -match '^\*\*\* (?:Move to|End of File)') {
374
- continue
375
- }
376
- if ($line.StartsWith('+') -and -not $line.StartsWith('+++')) {
377
- $newLines.Add($line.Substring(1))
378
- } elseif ($line.StartsWith('-') -and -not $line.StartsWith('---')) {
379
- $oldLines.Add($line.Substring(1))
380
- } elseif ($line.StartsWith(' ')) {
381
- $oldLines.Add($line.Substring(1))
382
- $newLines.Add($line.Substring(1))
383
- } else {
384
- $oldLines.Add($line)
385
- $newLines.Add($line)
386
- }
387
- }
388
- $oldText = $oldLines -join "`n"
389
- $newText = $newLines -join "`n"
390
- $index = $content.IndexOf($oldText, [System.StringComparison]::Ordinal)
391
- if ($index -lt 0) {
392
- throw "enforce-checkpoint-monotonic could not apply an in-memory patch hunk for '$path'."
393
- }
394
- $content = $content.Substring(0, $index) + $newText + $content.Substring($index + $oldText.Length)
395
- }
396
- }
397
- $inputs.Add([pscustomobject]@{ file_path = $path; content = $content })
398
- }
399
- return $inputs.ToArray()
400
- }
401
-
402
312
  if ($MyInvocation.InvocationName -eq '.') {
403
313
  return
404
314
  }
405
315
 
406
316
  try {
407
- $payload = ConvertFrom-CodexCheckpointHookPayload -PayloadRaw ([Console]::In.ReadToEnd())
408
- foreach ($toolInput in @(ConvertTo-CodexApplyPatchCheckpointInput -Payload $payload)) {
317
+ # Transport and mapping come from the shared module. Update reconstruction is
318
+ # requested for the governed checkpoint path only: an Update touching any
319
+ # other file yields no record and therefore allows, instead of failing the
320
+ # whole invocation because an unrelated source could not be read. A governed
321
+ # reconstruction failure yields empty content, which routes into the existing
322
+ # fail-closed deny below rather than exit 2.
323
+ $payload = ConvertFrom-CodexPreToolUsePayload -PayloadRaw ([Console]::In.ReadToEnd()) -HookName 'enforce-checkpoint-monotonic'
324
+ $mappedInputs = @(
325
+ ConvertTo-CodexFileEditInput -Payload $payload -ResolveUpdateContent -GovernedPath $script:GovernedCheckpointPath
326
+ )
327
+ foreach ($toolInput in $mappedInputs) {
409
328
  $toolInputRaw = $toolInput | ConvertTo-Json -Compress -Depth 20
410
329
  $decision = Invoke-CheckpointMonotonicDecision -ToolInputRaw $toolInputRaw
411
330
  if ($decision.hookSpecificOutput.permissionDecision -eq 'deny') {
@@ -41,9 +41,16 @@
41
41
  [CmdletBinding()]
42
42
  param()
43
43
 
44
- # Reuse the adjacent hook's read-only Codex apply_patch adapter.
44
+ # Reuse the adjacent hook's shared checkpoint policy logic. This dot-source is by
45
+ # design, not a copy-paste defect, so the hook is not renamed.
45
46
  . (Join-Path $PSScriptRoot 'enforce-checkpoint-monotonic.ps1')
46
47
 
48
+ # Shared Codex PreToolUse transport. Dot-sourced explicitly rather than relying on
49
+ # the transitive load above, so this hook's transport does not depend on its
50
+ # neighbour's internals. Every transport error it raises names this hook, because
51
+ # the shared parser is called with -HookName 'enforce-completion-consistency'.
52
+ . (Join-Path $PSScriptRoot 'codex-pretooluse-file-mapping.ps1')
53
+
47
54
  # Dot-source the shared validation helpers. Guarded so a missing file produces a
48
55
  # clear error and so dot-sourcing this hook in tests loads the helpers too.
49
56
  $script:CompletionHelpersPath = Join-Path $PSScriptRoot 'enforce-completion-helpers.ps1'
@@ -409,8 +416,14 @@ if ($MyInvocation.InvocationName -eq '.') {
409
416
  }
410
417
 
411
418
  try {
412
- $payload = ConvertFrom-CodexCheckpointHookPayload -PayloadRaw ([Console]::In.ReadToEnd())
413
- foreach ($toolInput in @(ConvertTo-CodexApplyPatchCheckpointInput -Payload $payload)) {
419
+ # -HookName makes every transport error name this hook rather than the
420
+ # neighbour it dot-sources. Update reconstruction is scoped to the governed
421
+ # checkpoint path for the same reason as in enforce-checkpoint-monotonic.
422
+ $payload = ConvertFrom-CodexPreToolUsePayload -PayloadRaw ([Console]::In.ReadToEnd()) -HookName 'enforce-completion-consistency'
423
+ $mappedInputs = @(
424
+ ConvertTo-CodexFileEditInput -Payload $payload -ResolveUpdateContent -GovernedPath $script:GovernedCheckpointPath
425
+ )
426
+ foreach ($toolInput in $mappedInputs) {
414
427
  $toolInputRaw = $toolInput | ConvertTo-Json -Compress -Depth 20
415
428
  $decision = Invoke-CompletionConsistencyDecision -ToolInputRaw $toolInputRaw
416
429
  if ($decision.hookSpecificOutput.permissionDecision -eq 'deny') {
@@ -267,6 +267,18 @@ function Invoke-CodexChildGuardGit {
267
267
  return & git @GitArgs 2>&1
268
268
  }
269
269
 
270
+ function Get-CodexChildGuardLiveBranch {
271
+ [CmdletBinding()]
272
+ [OutputType([string])]
273
+ param([Parameter(Mandatory)][string] $RepositoryRoot)
274
+
275
+ $liveBranch = [string](Invoke-CodexChildGuardGit -GitArgs @('-C', $RepositoryRoot, 'branch', '--show-current'))
276
+ if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($liveBranch)) {
277
+ return ''
278
+ }
279
+ return $liveBranch.Trim()
280
+ }
281
+
270
282
  if ($MyInvocation.InvocationName -eq '.') {
271
283
  return
272
284
  }
@@ -308,12 +320,9 @@ try {
308
320
  } else {
309
321
  ''
310
322
  }
311
- $liveBranch = [string](Invoke-CodexChildGuardGit -GitArgs @('-C', $repositoryRoot, 'branch', '--show-current'))
312
- if ($LASTEXITCODE -ne 0) {
313
- $liveBranch = ''
314
- }
323
+ $liveBranch = Get-CodexChildGuardLiveBranch -RepositoryRoot $repositoryRoot
315
324
  $decision = Invoke-CodexEpicChildGuardDecision -PayloadRaw $payloadRaw -ReceiptRaw $receiptRaw `
316
- -Attestation $attestation -HookRepositoryRoot $repositoryRoot -LiveBranch $liveBranch.Trim() `
325
+ -Attestation $attestation -HookRepositoryRoot $repositoryRoot -LiveBranch $liveBranch `
317
326
  -ActualSpecSha256 $actualSpecSha256 -ActualProfileSha256 $actualProfileSha256
318
327
  if ($null -ne $decision) {
319
328
  $decision | ConvertTo-Json -Compress -Depth 5 | Write-Output
@@ -245,6 +245,28 @@ function Invoke-EpicPlanningOnlyDecision {
245
245
  return Get-EpicPlanningDenyDecision -Reason "tool '$toolName' is not classified for preparation mode."
246
246
  }
247
247
 
248
+ function Invoke-EpicPlanningGit {
249
+ [CmdletBinding()]
250
+ param([Parameter(Mandatory)][string[]] $GitArgs)
251
+
252
+ return & git @GitArgs 2>$null
253
+ }
254
+
255
+ function Get-EpicPlanningCurrentBranch {
256
+ [CmdletBinding()]
257
+ [OutputType([string])]
258
+ param([Parameter(Mandatory)][string] $RepositoryRoot)
259
+
260
+ $currentBranch = [string](Invoke-EpicPlanningGit -GitArgs @('-C', $RepositoryRoot, 'branch', '--show-current'))
261
+ if ($LASTEXITCODE -ne 0) {
262
+ throw 'EPIC_PLANNING_ONLY_BLOCKED: current branch could not be resolved before push.'
263
+ }
264
+ if ([string]::IsNullOrWhiteSpace($currentBranch)) {
265
+ return ''
266
+ }
267
+ return $currentBranch.Trim()
268
+ }
269
+
248
270
  if ($MyInvocation.InvocationName -eq '.') {
249
271
  return
250
272
  }
@@ -270,11 +292,7 @@ try {
270
292
  }
271
293
  if ([string]$payload.tool_name -eq 'Bash' -and
272
294
  [string]$payload.tool_input.command -match '^\s*git\s+push\b') {
273
- $currentBranch = [string](& git -C $repositoryRoot branch --show-current 2>$null)
274
- if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($currentBranch)) {
275
- throw 'EPIC_PLANNING_ONLY_BLOCKED: current branch could not be resolved before push.'
276
- }
277
- $currentBranch = $currentBranch.Trim()
295
+ $currentBranch = Get-EpicPlanningCurrentBranch -RepositoryRoot $repositoryRoot
278
296
  }
279
297
  $decision = Invoke-EpicPlanningOnlyDecision `
280
298
  -PayloadRaw $payloadRaw `
@@ -30,9 +30,9 @@
30
30
 
31
31
  If the file_path resolves to a forbidden prefix, the script writes a PreToolUse JSON
32
32
  response to stdout with hookSpecificOutput.permissionDecision = 'deny' and exits with
33
- code 0 so Codex surfaces the reason. For allowed paths, a PreToolUse response
34
- with permissionDecision = 'allow' is written to stdout and the script exits 0. On hard
35
- failure (malformed JSON input), the script exits 2.
33
+ code 0 so Codex surfaces the reason. Allowed paths produce NO stdout at all: the
34
+ script allows silently and exits 0. On hard failure (empty, malformed, or
35
+ tool_input-less stdin) the script writes the reason to stderr and exits 2.
36
36
 
37
37
  .NOTES
38
38
  Compatible with PowerShell 7+.
@@ -41,6 +41,10 @@
41
41
  [CmdletBinding()]
42
42
  param()
43
43
 
44
+ # Shared Codex PreToolUse transport: stdin payload parsing and tool_input-to-file
45
+ # mapping for every tool name the ^(apply_patch|Edit|Write)$ matcher admits.
46
+ . (Join-Path $PSScriptRoot 'codex-pretooluse-file-mapping.ps1')
47
+
44
48
  function Test-EvidenceLocationForbidden {
45
49
  <#
46
50
  .SYNOPSIS
@@ -142,60 +146,34 @@ function Invoke-EvidenceLocationDecision {
142
146
  return [ordered]@{ hookSpecificOutput = [ordered]@{ hookEventName = 'PreToolUse'; permissionDecision = 'allow' } }
143
147
  }
144
148
 
145
- function ConvertFrom-CodexEvidenceLocationPayload {
146
- [CmdletBinding()]
147
- param([Parameter(Mandatory)][string] $PayloadRaw)
148
-
149
- if ([string]::IsNullOrWhiteSpace($PayloadRaw)) {
150
- throw 'enforce-evidence-locations hook input is empty.'
151
- }
152
- try {
153
- $payload = $PayloadRaw | ConvertFrom-Json -ErrorAction Stop
154
- } catch {
155
- throw "enforce-evidence-locations hook input is malformed JSON: $_"
156
- }
157
- if ($payload.PSObject.Properties.Name -notcontains 'tool_input' -or $null -eq $payload.tool_input) {
158
- throw 'enforce-evidence-locations hook input is missing tool_input.'
159
- }
160
- if ([string]$payload.hook_event_name -ne 'PreToolUse' -or [string]$payload.tool_name -ne 'apply_patch') {
161
- throw 'enforce-evidence-locations requires a PreToolUse apply_patch payload.'
162
- }
163
- return $payload
164
- }
165
-
166
- function Get-CodexEvidenceLocationPath {
167
- [CmdletBinding()]
168
- [OutputType([string])]
169
- param([Parameter(Mandatory)] $Payload)
170
-
171
- if ($Payload.tool_input.PSObject.Properties.Name -contains 'file_path') {
172
- return [string]$Payload.tool_input.file_path
173
- }
174
- $command = [string]$Payload.tool_input.command
175
- if ([string]::IsNullOrWhiteSpace($command)) {
176
- throw 'enforce-evidence-locations cannot map tool_input to a file edit.'
177
- }
178
- [string[]] $paths = @(
179
- [regex]::Matches($command, '(?m)^\*\*\* (?:(?:Add|Update|Delete) File|Move to):\s*(?<path>.+?)\s*$') |
180
- ForEach-Object { ([string]$_.Groups['path'].Value).Trim() }
181
- )
182
- $paths = @($paths | Select-Object -Unique)
183
- if ($paths.Count -eq 0) {
184
- throw 'enforce-evidence-locations received an unrecognized apply_patch command.'
185
- }
186
- foreach ($path in $paths) {
187
- Write-Output ([string]$path)
188
- }
189
- }
190
-
191
149
  function Invoke-EvidenceLocationEntryPoint {
150
+ <#
151
+ .SYNOPSIS
152
+ Reads one Codex PreToolUse payload and returns the process exit code.
153
+ .PARAMETER PayloadRaw
154
+ The raw stdin text. AllowEmptyString is required so empty input reaches
155
+ the shared parser and fails closed with exit 2, rather than raising a
156
+ parameter-binding error that would skip the exit and allow silently.
157
+ #>
192
158
  [CmdletBinding()]
193
159
  [OutputType([int])]
194
- param([Parameter(Mandatory)][string] $PayloadRaw)
160
+ param([Parameter(Mandatory)][AllowEmptyString()][string] $PayloadRaw)
195
161
 
196
162
  try {
197
- $payload = ConvertFrom-CodexEvidenceLocationPayload -PayloadRaw $PayloadRaw
198
- foreach ($path in @(Get-CodexEvidenceLocationPath -Payload $payload)) {
163
+ $payload = ConvertFrom-CodexPreToolUsePayload -PayloadRaw $PayloadRaw -HookName 'enforce-evidence-locations'
164
+
165
+ # Both sides of a rename are evaluated, matching the pre-fix path scan
166
+ # that collected Add/Update/Delete targets and Move destinations alike.
167
+ # A well-formed payload that maps to no file yields no paths, so the loop
168
+ # body never runs and the hook allows silently.
169
+ $paths = @(
170
+ @(ConvertTo-CodexFileEditInput -Payload $payload) |
171
+ ForEach-Object { $_.source_path; $_.file_path } |
172
+ Where-Object { -not [string]::IsNullOrWhiteSpace($_) } |
173
+ Select-Object -Unique
174
+ )
175
+
176
+ foreach ($path in $paths) {
199
177
  $toolInputRaw = @{ file_path = $path } | ConvertTo-Json -Compress
200
178
  $decision = Invoke-EvidenceLocationDecision -ToolInputRaw $toolInputRaw
201
179
  if ($decision.hookSpecificOutput.permissionDecision -eq 'deny') {
@@ -6,6 +6,10 @@
6
6
  [CmdletBinding()]
7
7
  param()
8
8
 
9
+ # Shared Codex PreToolUse transport: stdin payload parsing and tool_input-to-file
10
+ # mapping for every tool name the ^(apply_patch|Edit|Write)$ matcher admits.
11
+ . (Join-Path $PSScriptRoot 'codex-pretooluse-file-mapping.ps1')
12
+
9
13
  $script:CheckpointPath = 'artifacts/orchestration/orchestrator-state.json'
10
14
 
11
15
  function ConvertFrom-CheckpointJson {
@@ -224,38 +228,35 @@ function Invoke-OrchestrationPreimplementationGateDecision {
224
228
  return Get-OrchestrationPreimplementationGateBlockDecision -Reason 'PREIMPLEMENTATION_GATE_BLOCKED: Implementation operations require artifacts/orchestration/orchestrator-state.json to contain issue number, feature folder, route metadata, lifecycle readiness, and checkpoint state before implementation begins.'
225
229
  }
226
230
 
227
- function ConvertFrom-CodexPreimplementationHookPayload {
228
- [CmdletBinding()]
229
- param([Parameter(Mandatory)][string] $PayloadRaw)
230
-
231
- if ([string]::IsNullOrWhiteSpace($PayloadRaw)) {
232
- throw 'enforce-orchestration-preimplementation-gate hook input is empty.'
233
- }
234
- try {
235
- $payload = $PayloadRaw | ConvertFrom-Json -ErrorAction Stop
236
- } catch {
237
- throw "enforce-orchestration-preimplementation-gate hook input is malformed JSON: $_"
238
- }
239
- if ($payload.PSObject.Properties.Name -notcontains 'tool_input' -or $null -eq $payload.tool_input) {
240
- throw 'enforce-orchestration-preimplementation-gate hook input is missing tool_input.'
241
- }
242
- if ([string]$payload.hook_event_name -ne 'PreToolUse' -or
243
- @('Bash', 'apply_patch') -notcontains [string]$payload.tool_name) {
244
- throw 'enforce-orchestration-preimplementation-gate requires a supported PreToolUse payload.'
245
- }
246
- return $payload
247
- }
248
-
249
231
  if ($MyInvocation.InvocationName -eq '.') {
250
232
  return
251
233
  }
252
234
 
253
235
  try {
254
- $payload = ConvertFrom-CodexPreimplementationHookPayload -PayloadRaw ([Console]::In.ReadToEnd())
255
- $toolInputRaw = $payload.tool_input | ConvertTo-Json -Compress -Depth 20
256
- $decision = Invoke-OrchestrationPreimplementationGateDecision -ToolInputRaw $toolInputRaw
257
- if ($decision.hookSpecificOutput.permissionDecision -eq 'deny') {
258
- $decision | ConvertTo-Json -Compress -Depth 5 | Write-Output
236
+ $payload = ConvertFrom-CodexPreToolUsePayload -PayloadRaw ([Console]::In.ReadToEnd()) -HookName 'enforce-orchestration-preimplementation-gate'
237
+ $toolName = [string]$payload.tool_name
238
+
239
+ # Bash and apply_patch take the pre-fix path unchanged: the raw tool_input is
240
+ # serialized and evaluated by the untouched decision function, so every
241
+ # allow/deny outcome those two tool names produce today is preserved exactly.
242
+ if (@('Bash', 'apply_patch') -contains $toolName) {
243
+ $decision = Invoke-OrchestrationPreimplementationGateDecision -ToolInputRaw ($payload.tool_input | ConvertTo-Json -Compress -Depth 20)
244
+ if ($decision.hookSpecificOutput.permissionDecision -eq 'deny') {
245
+ $decision | ConvertTo-Json -Compress -Depth 5 | Write-Output
246
+ }
247
+ exit 0
248
+ }
249
+
250
+ # Edit and Write map to file paths through the shared module; each mapped
251
+ # path enters the same untouched Test-ImplementationPath decision flow. Any
252
+ # other well-formed tool name maps to no records, so the hook allows.
253
+ foreach ($toolInput in @(ConvertTo-CodexFileEditInput -Payload $payload)) {
254
+ $mappedRaw = @{ file_path = [string]$toolInput.file_path } | ConvertTo-Json -Compress
255
+ $decision = Invoke-OrchestrationPreimplementationGateDecision -ToolInputRaw $mappedRaw
256
+ if ($decision.hookSpecificOutput.permissionDecision -eq 'deny') {
257
+ $decision | ConvertTo-Json -Compress -Depth 5 | Write-Output
258
+ exit 0
259
+ }
259
260
  }
260
261
  exit 0
261
262
  } catch {
@@ -36,6 +36,10 @@
36
36
  [CmdletBinding()]
37
37
  param()
38
38
 
39
+ # Shared Codex PreToolUse transport: stdin payload parsing and tool_input-to-file
40
+ # mapping for every tool name the ^(apply_patch|Edit|Write)$ matcher admits.
41
+ . (Join-Path $PSScriptRoot 'codex-pretooluse-file-mapping.ps1')
42
+
39
43
  function Get-PowerShellBatchBudgetState {
40
44
  [CmdletBinding()]
41
45
  [OutputType([System.Collections.Specialized.OrderedDictionary])]
@@ -212,65 +216,31 @@ function Invoke-PowerShellBatchBudgetHook {
212
216
  return $decision
213
217
  }
214
218
 
215
- function ConvertFrom-CodexPowerShellBudgetPayload {
216
- [CmdletBinding()]
217
- param([Parameter(Mandatory)][string] $PayloadRaw)
218
-
219
- if ([string]::IsNullOrWhiteSpace($PayloadRaw)) {
220
- throw 'enforce-powershell-batch-budget hook input is empty.'
221
- }
222
- try {
223
- $payload = $PayloadRaw | ConvertFrom-Json -ErrorAction Stop
224
- } catch {
225
- throw "enforce-powershell-batch-budget hook input is malformed JSON: $_"
226
- }
227
- if ($payload.PSObject.Properties.Name -notcontains 'tool_input' -or $null -eq $payload.tool_input) {
228
- throw 'enforce-powershell-batch-budget hook input is missing tool_input.'
229
- }
230
- if ([string]$payload.hook_event_name -ne 'PreToolUse' -or [string]$payload.tool_name -ne 'apply_patch') {
231
- throw 'enforce-powershell-batch-budget requires a PreToolUse apply_patch payload.'
232
- }
233
- if ([string]::IsNullOrWhiteSpace([string]$payload.session_id)) {
234
- throw 'enforce-powershell-batch-budget hook input is missing session_id.'
235
- }
236
- return $payload
237
- }
238
-
239
- function Get-CodexPowerShellBudgetPath {
240
- [CmdletBinding()]
241
- [OutputType([string])]
242
- param([Parameter(Mandatory)] $Payload)
243
-
244
- if ($Payload.tool_input.PSObject.Properties.Name -contains 'file_path') {
245
- return [string]$Payload.tool_input.file_path
246
- }
247
- $command = [string]$Payload.tool_input.command
248
- if ([string]::IsNullOrWhiteSpace($command)) {
249
- throw 'enforce-powershell-batch-budget cannot map tool_input to a file edit.'
250
- }
251
- [string[]] $paths = @(
252
- [regex]::Matches($command, '(?m)^\*\*\* (?:(?:Add|Update|Delete) File|Move to):\s*(?<path>.+?)\s*$') |
253
- ForEach-Object { ([string]$_.Groups['path'].Value).Trim() }
254
- )
255
- $paths = @($paths | Select-Object -Unique)
256
- if ($paths.Count -eq 0) {
257
- throw 'enforce-powershell-batch-budget received an unrecognized apply_patch command.'
258
- }
259
- return $paths
260
- }
261
-
262
219
  if ($MyInvocation.InvocationName -eq '.') {
263
220
  return
264
221
  }
265
222
 
266
223
  try {
267
- $payload = ConvertFrom-CodexPowerShellBudgetPayload -PayloadRaw ([Console]::In.ReadToEnd())
224
+ # Transport and mapping come from the shared module. session_id is still
225
+ # required because the batch counter is keyed by it.
226
+ $payload = ConvertFrom-CodexPreToolUsePayload -PayloadRaw ([Console]::In.ReadToEnd()) -HookName 'enforce-powershell-batch-budget' -RequireSessionId
268
227
  $sessionId = ([string]$payload.session_id) -replace '[^A-Za-z0-9._-]', '_'
269
228
  $repositoryRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
270
229
  $prodCap = 3
271
230
  $testCap = 3
272
231
 
273
- foreach ($path in @(Get-CodexPowerShellBudgetPath -Payload $payload)) {
232
+ # Both sides of a rename consume budget, matching the pre-fix path scan that
233
+ # collected Add/Update/Delete targets and Move destinations alike. Ordering
234
+ # and de-duplication are preserved. A well-formed payload that maps to no
235
+ # file yields no paths, so no state is written and the hook allows silently.
236
+ $budgetPaths = @(
237
+ @(ConvertTo-CodexFileEditInput -Payload $payload) |
238
+ ForEach-Object { $_.source_path; $_.file_path } |
239
+ Where-Object { -not [string]::IsNullOrWhiteSpace($_) } |
240
+ Select-Object -Unique
241
+ )
242
+
243
+ foreach ($path in $budgetPaths) {
274
244
  $toolInputRaw = @{ file_path = $path } | ConvertTo-Json -Compress
275
245
  $decision = Invoke-PowerShellBatchBudgetHook -ToolInputRaw $toolInputRaw -SessionId $sessionId -Root $repositoryRoot -ProdCap $prodCap -TestCap $testCap
276
246
  if ($decision.hookSpecificOutput.permissionDecision -eq 'deny') {
@@ -34,6 +34,10 @@
34
34
  [CmdletBinding()]
35
35
  param()
36
36
 
37
+ # Shared Codex PreToolUse transport: stdin payload parsing and tool_input-to-file
38
+ # mapping for every tool name the ^(apply_patch|Edit|Write)$ matcher admits.
39
+ . (Join-Path $PSScriptRoot 'codex-pretooluse-file-mapping.ps1')
40
+
37
41
  function Get-PythonBatchBudgetState {
38
42
  [CmdletBinding()]
39
43
  [OutputType([System.Collections.Specialized.OrderedDictionary])]
@@ -210,65 +214,31 @@ function Invoke-PythonBatchBudgetHook {
210
214
  return $decision
211
215
  }
212
216
 
213
- function ConvertFrom-CodexPythonBudgetPayload {
214
- [CmdletBinding()]
215
- param([Parameter(Mandatory)][string] $PayloadRaw)
216
-
217
- if ([string]::IsNullOrWhiteSpace($PayloadRaw)) {
218
- throw 'enforce-python-batch-budget hook input is empty.'
219
- }
220
- try {
221
- $payload = $PayloadRaw | ConvertFrom-Json -ErrorAction Stop
222
- } catch {
223
- throw "enforce-python-batch-budget hook input is malformed JSON: $_"
224
- }
225
- if ($payload.PSObject.Properties.Name -notcontains 'tool_input' -or $null -eq $payload.tool_input) {
226
- throw 'enforce-python-batch-budget hook input is missing tool_input.'
227
- }
228
- if ([string]$payload.hook_event_name -ne 'PreToolUse' -or [string]$payload.tool_name -ne 'apply_patch') {
229
- throw 'enforce-python-batch-budget requires a PreToolUse apply_patch payload.'
230
- }
231
- if ([string]::IsNullOrWhiteSpace([string]$payload.session_id)) {
232
- throw 'enforce-python-batch-budget hook input is missing session_id.'
233
- }
234
- return $payload
235
- }
236
-
237
- function Get-CodexPythonBudgetPath {
238
- [CmdletBinding()]
239
- [OutputType([string])]
240
- param([Parameter(Mandatory)] $Payload)
241
-
242
- if ($Payload.tool_input.PSObject.Properties.Name -contains 'file_path') {
243
- return [string]$Payload.tool_input.file_path
244
- }
245
- $command = [string]$Payload.tool_input.command
246
- if ([string]::IsNullOrWhiteSpace($command)) {
247
- throw 'enforce-python-batch-budget cannot map tool_input to a file edit.'
248
- }
249
- [string[]] $paths = @(
250
- [regex]::Matches($command, '(?m)^\*\*\* (?:(?:Add|Update|Delete) File|Move to):\s*(?<path>.+?)\s*$') |
251
- ForEach-Object { ([string]$_.Groups['path'].Value).Trim() }
252
- )
253
- $paths = @($paths | Select-Object -Unique)
254
- if ($paths.Count -eq 0) {
255
- throw 'enforce-python-batch-budget received an unrecognized apply_patch command.'
256
- }
257
- return $paths
258
- }
259
-
260
217
  if ($MyInvocation.InvocationName -eq '.') {
261
218
  return
262
219
  }
263
220
 
264
221
  try {
265
- $payload = ConvertFrom-CodexPythonBudgetPayload -PayloadRaw ([Console]::In.ReadToEnd())
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
266
225
  $sessionId = ([string]$payload.session_id) -replace '[^A-Za-z0-9._-]', '_'
267
226
  $repositoryRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
268
227
  $prodCap = 3
269
228
  $testCap = 3
270
229
 
271
- foreach ($path in @(Get-CodexPythonBudgetPath -Payload $payload)) {
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
239
+ )
240
+
241
+ foreach ($path in $budgetPaths) {
272
242
  $toolInputRaw = @{ file_path = $path } | ConvertTo-Json -Compress
273
243
  $decision = Invoke-PythonBatchBudgetHook -ToolInputRaw $toolInputRaw -SessionId $sessionId -Root $repositoryRoot -ProdCap $prodCap -TestCap $testCap
274
244
  if ($decision.hookSpecificOutput.permissionDecision -eq 'deny') {
@@ -29,6 +29,7 @@
29
29
  ".codex/hooks/codex-agent-profile-attestation.ps1",
30
30
  ".codex/hooks/codex-authority-store.ps1",
31
31
  ".codex/hooks/codex-epic-child-launch-attestation.ps1",
32
+ ".codex/hooks/codex-pretooluse-file-mapping.ps1",
32
33
  ".codex/hooks/enforce-codex-model-routing.ps1",
33
34
  ".codex/hooks/enforce-epic-child-worktree-binding.ps1",
34
35
  ".codex/hooks/enforce-epic-merge-gate.ps1",
@@ -95,6 +95,25 @@
95
95
  # entry path hosts the Pester run in the global session state; measured here so
96
96
  # the fix produces real per-file changed-line coverage evidence.
97
97
  'scripts/powershell/PoshQC/PoshQC.Testing.psm1'
98
+ # Issue #415 remediation cycle 1 (R1): the PreToolUse hook transport fix added the
99
+ # shared payload-parsing module and rewired seven Codex hooks to consume it. Those
100
+ # production files were absent from this list, so the changed surface was outside
101
+ # the coverage denominator. Measured here so the changed production surface is not
102
+ # excluded from coverage.
103
+ '.codex/hooks/codex-pretooluse-file-mapping.ps1'
104
+ '.codex/hooks/check-python-test-purity.ps1'
105
+ '.codex/hooks/check-powershell-test-purity.ps1'
106
+ '.codex/hooks/enforce-python-batch-budget.ps1'
107
+ '.codex/hooks/enforce-powershell-batch-budget.ps1'
108
+ '.codex/hooks/enforce-evidence-locations.ps1'
109
+ '.codex/hooks/enforce-checkpoint-monotonic.ps1'
110
+ '.codex/hooks/enforce-orchestration-preimplementation-gate.ps1'
111
+ # Issue #415 remediation cycle 2 (R-COV): the detached-HEAD null-guard fix changed
112
+ # these two Codex PreToolUse hooks. Both were absent from this list, so the changed
113
+ # production surface was outside the coverage denominator. Measured here so the
114
+ # changed production surface is not excluded from coverage.
115
+ '.codex/hooks/enforce-epic-child-worktree-binding.ps1'
116
+ '.codex/hooks/enforce-epic-planning-only.ps1'
98
117
  )
99
118
  # Optional: don't fail the run on coverage percentage
100
119
  CoveragePercentTarget = 0