@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
@@ -33,6 +33,10 @@
33
33
  [CmdletBinding()]
34
34
  param()
35
35
 
36
+ # Shared Codex PreToolUse transport: stdin payload parsing and tool_input-to-file
37
+ # mapping for every tool name the ^(apply_patch|Edit|Write)$ matcher admits.
38
+ . (Join-Path $PSScriptRoot 'codex-pretooluse-file-mapping.ps1')
39
+
36
40
  function Get-PowerShellTestPurityBlockDecision {
37
41
  [CmdletBinding()]
38
42
  [OutputType([System.Collections.Specialized.OrderedDictionary])]
@@ -138,76 +142,16 @@ function Invoke-PowerShellTestPurityDecision {
138
142
  return Get-PowerShellTestPurityBlockDecision -Reason $reason
139
143
  }
140
144
 
141
- function ConvertFrom-CodexPowerShellPurityPayload {
142
- [CmdletBinding()]
143
- param([Parameter(Mandatory)][string] $PayloadRaw)
144
-
145
- if ([string]::IsNullOrWhiteSpace($PayloadRaw)) {
146
- throw 'check-powershell-test-purity hook input is empty.'
147
- }
148
- try {
149
- $payload = $PayloadRaw | ConvertFrom-Json -ErrorAction Stop
150
- } catch {
151
- throw "check-powershell-test-purity hook input is malformed JSON: $_"
152
- }
153
- if ($payload.PSObject.Properties.Name -notcontains 'tool_input' -or $null -eq $payload.tool_input) {
154
- throw 'check-powershell-test-purity hook input is missing tool_input.'
155
- }
156
- if ([string]$payload.hook_event_name -ne 'PreToolUse' -or [string]$payload.tool_name -ne 'apply_patch') {
157
- throw 'check-powershell-test-purity requires a PreToolUse apply_patch payload.'
158
- }
159
- return $payload
160
- }
161
-
162
- function ConvertTo-CodexPowerShellPurityInput {
163
- [CmdletBinding()]
164
- [OutputType([object[]])]
165
- param([Parameter(Mandatory)] $Payload)
166
-
167
- if ($Payload.tool_input.PSObject.Properties.Name -contains 'file_path') {
168
- return , $Payload.tool_input
169
- }
170
-
171
- $command = [string]$Payload.tool_input.command
172
- if ([string]::IsNullOrWhiteSpace($command)) {
173
- throw 'check-powershell-test-purity cannot map tool_input to a file edit.'
174
- }
175
-
176
- $fileMatches = [regex]::Matches(
177
- $command,
178
- '(?ms)^\*\*\* (?:Add|Update|Delete) File:\s*(?<path>.+?)\r?\n(?<body>.*?)(?=^\*\*\* (?:(?:Add|Update|Delete) File:|End Patch)\s*|\z)'
179
- )
180
- if ($fileMatches.Count -eq 0) {
181
- throw 'check-powershell-test-purity received an unrecognized apply_patch command.'
182
- }
183
-
184
- $inputs = [System.Collections.Generic.List[object]]::new()
185
- foreach ($match in $fileMatches) {
186
- $filePath = ([string]$match.Groups['path'].Value).Trim()
187
- $moveMatch = [regex]::Match([string]$match.Groups['body'].Value, '(?m)^\*\*\* Move to:\s*(?<path>.+?)\s*$')
188
- if ($moveMatch.Success) {
189
- $filePath = ([string]$moveMatch.Groups['path'].Value).Trim()
190
- }
191
- $addedLines = foreach ($line in ([string]$match.Groups['body'].Value -split '\r?\n')) {
192
- if ($line.StartsWith('+') -and -not $line.StartsWith('+++')) {
193
- $line.Substring(1)
194
- }
195
- }
196
- $inputs.Add([pscustomobject]@{
197
- file_path = $filePath
198
- content = $addedLines -join [Environment]::NewLine
199
- })
200
- }
201
- return $inputs.ToArray()
202
- }
203
-
204
145
  if ($MyInvocation.InvocationName -eq '.') {
205
146
  return
206
147
  }
207
148
 
208
149
  try {
209
- $payload = ConvertFrom-CodexPowerShellPurityPayload -PayloadRaw ([Console]::In.ReadToEnd())
210
- foreach ($toolInput in @(ConvertTo-CodexPowerShellPurityInput -Payload $payload)) {
150
+ # Transport and mapping come from the shared module. A well-formed payload
151
+ # that maps to no file edit produces an empty record set, so the loop body
152
+ # never runs and the hook allows silently.
153
+ $payload = ConvertFrom-CodexPreToolUsePayload -PayloadRaw ([Console]::In.ReadToEnd()) -HookName 'check-powershell-test-purity'
154
+ foreach ($toolInput in @(ConvertTo-CodexFileEditInput -Payload $payload)) {
211
155
  $toolInputRaw = $toolInput | ConvertTo-Json -Compress -Depth 20
212
156
  $decision = Invoke-PowerShellTestPurityDecision -ToolInputRaw $toolInputRaw
213
157
  if ($null -ne $decision -and $decision.hookSpecificOutput.permissionDecision -eq 'deny') {
@@ -30,6 +30,10 @@
30
30
  [CmdletBinding()]
31
31
  param()
32
32
 
33
+ # Shared Codex PreToolUse transport: stdin payload parsing and tool_input-to-file
34
+ # mapping for every tool name the ^(apply_patch|Edit|Write)$ matcher admits.
35
+ . (Join-Path $PSScriptRoot 'codex-pretooluse-file-mapping.ps1')
36
+
33
37
  function Get-PythonTestPurityBlockDecision {
34
38
  [CmdletBinding()]
35
39
  [OutputType([System.Collections.Specialized.OrderedDictionary])]
@@ -138,76 +142,16 @@ function Invoke-PythonTestPurityDecision {
138
142
  return Get-PythonTestPurityBlockDecision -Reason $reason
139
143
  }
140
144
 
141
- function ConvertFrom-CodexPythonPurityPayload {
142
- [CmdletBinding()]
143
- param([Parameter(Mandatory)][string] $PayloadRaw)
144
-
145
- if ([string]::IsNullOrWhiteSpace($PayloadRaw)) {
146
- throw 'check-python-test-purity hook input is empty.'
147
- }
148
- try {
149
- $payload = $PayloadRaw | ConvertFrom-Json -ErrorAction Stop
150
- } catch {
151
- throw "check-python-test-purity hook input is malformed JSON: $_"
152
- }
153
- if ($payload.PSObject.Properties.Name -notcontains 'tool_input' -or $null -eq $payload.tool_input) {
154
- throw 'check-python-test-purity hook input is missing tool_input.'
155
- }
156
- if ([string]$payload.hook_event_name -ne 'PreToolUse' -or [string]$payload.tool_name -ne 'apply_patch') {
157
- throw 'check-python-test-purity requires a PreToolUse apply_patch payload.'
158
- }
159
- return $payload
160
- }
161
-
162
- function ConvertTo-CodexPythonPurityInput {
163
- [CmdletBinding()]
164
- [OutputType([object[]])]
165
- param([Parameter(Mandatory)] $Payload)
166
-
167
- if ($Payload.tool_input.PSObject.Properties.Name -contains 'file_path') {
168
- return , $Payload.tool_input
169
- }
170
-
171
- $command = [string]$Payload.tool_input.command
172
- if ([string]::IsNullOrWhiteSpace($command)) {
173
- throw 'check-python-test-purity cannot map tool_input to a file edit.'
174
- }
175
-
176
- $fileMatches = [regex]::Matches(
177
- $command,
178
- '(?ms)^\*\*\* (?:Add|Update|Delete) File:\s*(?<path>.+?)\r?\n(?<body>.*?)(?=^\*\*\* (?:(?:Add|Update|Delete) File:|End Patch)\s*|\z)'
179
- )
180
- if ($fileMatches.Count -eq 0) {
181
- throw 'check-python-test-purity received an unrecognized apply_patch command.'
182
- }
183
-
184
- $inputs = [System.Collections.Generic.List[object]]::new()
185
- foreach ($match in $fileMatches) {
186
- $filePath = ([string]$match.Groups['path'].Value).Trim()
187
- $moveMatch = [regex]::Match([string]$match.Groups['body'].Value, '(?m)^\*\*\* Move to:\s*(?<path>.+?)\s*$')
188
- if ($moveMatch.Success) {
189
- $filePath = ([string]$moveMatch.Groups['path'].Value).Trim()
190
- }
191
- $addedLines = foreach ($line in ([string]$match.Groups['body'].Value -split '\r?\n')) {
192
- if ($line.StartsWith('+') -and -not $line.StartsWith('+++')) {
193
- $line.Substring(1)
194
- }
195
- }
196
- $inputs.Add([pscustomobject]@{
197
- file_path = $filePath
198
- content = $addedLines -join [Environment]::NewLine
199
- })
200
- }
201
- return $inputs.ToArray()
202
- }
203
-
204
145
  if ($MyInvocation.InvocationName -eq '.') {
205
146
  return
206
147
  }
207
148
 
208
149
  try {
209
- $payload = ConvertFrom-CodexPythonPurityPayload -PayloadRaw ([Console]::In.ReadToEnd())
210
- foreach ($toolInput in @(ConvertTo-CodexPythonPurityInput -Payload $payload)) {
150
+ # Transport and mapping come from the shared module. A well-formed payload
151
+ # that maps to no file edit produces an empty record set, so the loop body
152
+ # never runs and the hook allows silently.
153
+ $payload = ConvertFrom-CodexPreToolUsePayload -PayloadRaw ([Console]::In.ReadToEnd()) -HookName 'check-python-test-purity'
154
+ foreach ($toolInput in @(ConvertTo-CodexFileEditInput -Payload $payload)) {
211
155
  $toolInputRaw = $toolInput | ConvertTo-Json -Compress -Depth 20
212
156
  $decision = Invoke-PythonTestPurityDecision -ToolInputRaw $toolInputRaw
213
157
  if ($null -ne $decision -and $decision.hookSpecificOutput.permissionDecision -eq 'deny') {
@@ -0,0 +1,474 @@
1
+ <#
2
+ .SYNOPSIS
3
+ Shared Codex PreToolUse transport parsing and tool_input-to-file-edit mapping.
4
+
5
+ .DESCRIPTION
6
+ Dot-sourced by every hook registered under the .codex/config.toml matcher
7
+ ^(apply_patch|Edit|Write)$. It provides the two transport concerns those
8
+ hooks previously duplicated with drift:
9
+
10
+ - ConvertFrom-CodexPreToolUsePayload: validates and parses the single JSON
11
+ object a Codex PreToolUse hook receives on stdin. It throws only for
12
+ genuinely un-processable input, and every message it throws begins with
13
+ the caller-supplied hook name so each handler's stderr names itself.
14
+
15
+ - ConvertTo-CodexFileEditInput: maps a parsed payload to zero or more
16
+ file-edit records for the admitted tool names apply_patch, Edit, and
17
+ Write. Well-formed input that names nothing the caller governs maps to an
18
+ EMPTY array, which callers translate to allow (exit 0, no stdout) rather
19
+ than to a transport failure.
20
+
21
+ Separating these concerns is required because enforce-checkpoint-monotonic.ps1
22
+ and enforce-completion-consistency.ps1 are already near the 500-line cap and
23
+ cannot absorb inline mapping logic.
24
+
25
+ PUBLIC SURFACE. Consuming hooks call exactly the two functions named above
26
+ and nothing else. ConvertTo-CodexAddedLineText, Test-CodexGovernedPath, and
27
+ Resolve-CodexUpdatedFileContent are INTERNAL helpers of
28
+ ConvertTo-CodexFileEditInput; they exist only because the repository's code
29
+ standards require long branching logic to be factored into small, focused
30
+ functions rather than inlined, and no hook calls them directly.
31
+
32
+ This script performs no policy evaluation. Each consuming hook keeps its own
33
+ allow/deny policy functions unchanged and applies them to the records
34
+ returned here.
35
+
36
+ .NOTES
37
+ Compatible with PowerShell 7+.
38
+
39
+ Entrypoint-free by design: this file contains only script-scoped constants
40
+ and function definitions, so dot-sourcing it has no side effects and no
41
+ exit-code semantics. The precedent is enforce-completion-helpers.ps1, which
42
+ is likewise dot-sourced and carries no entrypoint. Consequently this file
43
+ never reads standard input and never reads any legacy Claude environment
44
+ variable; reading the payload is the calling hook's responsibility, and the
45
+ Pester parity suite asserts that no legacy environment read appears here.
46
+ #>
47
+ [CmdletBinding()]
48
+ param()
49
+
50
+ # Tool names the ^(apply_patch|Edit|Write)$ PreToolUse matcher admits. Any other
51
+ # well-formed tool name maps to no records, which the caller treats as allow.
52
+ $script:CodexAdmittedToolNames = @('apply_patch', 'Edit', 'Write')
53
+
54
+ # Splits an apply_patch command into one match per touched file. The lookahead
55
+ # terminates each file body at the next file marker or at '*** End Patch', so a
56
+ # multi-file patch yields one match per file. Lifted verbatim from the
57
+ # per-handler implementations this module replaces.
58
+ $script:CodexApplyPatchFileRegex = '(?ms)^\*\*\* (?<operation>Add|Update|Delete) File:\s*(?<path>.+?)\r?\n(?<body>.*?)(?=^\*\*\* (?:(?:Add|Update|Delete) File:|End Patch)\s*|\z)'
59
+
60
+ # Locates a rename destination inside a single file body.
61
+ $script:CodexApplyPatchMoveRegex = '(?m)^\*\*\* Move to:\s*(?<path>.+?)\s*$'
62
+
63
+ # Splits an apply_patch Update body into hunks on its @@ headers.
64
+ $script:CodexApplyPatchHunkRegex = '(?m)^@@[^\r\n]*\r?\n'
65
+
66
+ function ConvertFrom-CodexPreToolUsePayload {
67
+ <#
68
+ .SYNOPSIS
69
+ Parses and validates one Codex PreToolUse stdin payload.
70
+
71
+ .DESCRIPTION
72
+ Converts the raw JSON string a PreToolUse hook read from stdin into an
73
+ object, throwing only when the input cannot be processed at all. The
74
+ caller converts a throw into exit 2 with the message on stderr.
75
+
76
+ This function deliberately performs NO tool-name assertion. Admission is
77
+ the matcher's job in .codex/config.toml, and narrowing it here is the
78
+ defect that made every Edit and Write payload exit 2. Tool-name routing
79
+ belongs to ConvertTo-CodexFileEditInput, which maps unadmitted names to
80
+ no records so the caller allows.
81
+
82
+ .PARAMETER PayloadRaw
83
+ The raw stdin text. Empty, whitespace-only, and null are all rejected.
84
+ AllowEmptyString is required so the explicit, hook-named error below runs
85
+ instead of a generic parameter-binding failure whose message would not
86
+ name the hook.
87
+
88
+ .PARAMETER HookName
89
+ The calling hook's name. Every thrown message begins with this value so
90
+ a handler that shares this module still reports itself in its stderr.
91
+
92
+ .PARAMETER RequireSessionId
93
+ When present, a missing, empty, or whitespace-only session_id is also
94
+ rejected. Only the batch-budget hooks need this, because they key
95
+ per-session state by session_id.
96
+
97
+ .OUTPUTS
98
+ The parsed payload object.
99
+
100
+ .NOTES
101
+ Throws for exactly four conditions and nothing else: empty or
102
+ whitespace-only input, invalid JSON, missing or null tool_input, and
103
+ (only under -RequireSessionId) a missing or empty session_id.
104
+ #>
105
+ [CmdletBinding()]
106
+ [OutputType([object])]
107
+ param(
108
+ [Parameter(Mandatory)]
109
+ [AllowNull()]
110
+ [AllowEmptyString()]
111
+ [string] $PayloadRaw,
112
+
113
+ [Parameter(Mandatory)]
114
+ [ValidateNotNullOrEmpty()]
115
+ [string] $HookName,
116
+
117
+ [Parameter()]
118
+ [switch] $RequireSessionId
119
+ )
120
+
121
+ if ([string]::IsNullOrWhiteSpace($PayloadRaw)) {
122
+ throw "$HookName hook input is empty."
123
+ }
124
+
125
+ try {
126
+ $payload = $PayloadRaw | ConvertFrom-Json -ErrorAction Stop
127
+ } catch {
128
+ throw "$HookName hook input is malformed JSON: $_"
129
+ }
130
+
131
+ # A JSON literal such as 'null', a bare string, or a number parses without
132
+ # error but carries no tool_input, so both cases fail through one check.
133
+ if ($null -eq $payload -or
134
+ $payload.PSObject.Properties.Name -notcontains 'tool_input' -or
135
+ $null -eq $payload.tool_input) {
136
+ throw "$HookName hook input is missing tool_input."
137
+ }
138
+
139
+ if ($RequireSessionId -and [string]::IsNullOrWhiteSpace([string]$payload.session_id)) {
140
+ throw "$HookName hook input is missing session_id."
141
+ }
142
+
143
+ return $payload
144
+ }
145
+
146
+ function ConvertTo-CodexFileEditInput {
147
+ <#
148
+ .SYNOPSIS
149
+ Maps a parsed Codex PreToolUse payload to zero or more file-edit records.
150
+
151
+ .DESCRIPTION
152
+ Admits the tool names apply_patch, Edit, and Write. Every other
153
+ well-formed tool name, and every admitted tool name whose tool_input
154
+ names no file, maps to an EMPTY array so the caller allows.
155
+
156
+ Each emitted record carries:
157
+
158
+ file_path - the path the operation ultimately affects; for an
159
+ apply_patch rename this is the '*** Move to:'
160
+ destination, matching the pre-fix adapters.
161
+ source_path - the path the operation started from; equal to file_path
162
+ except for a rename.
163
+ operation - Add, Update, Delete, Edit, or Write.
164
+ content, old_string, new_string - whichever fields the operation has.
165
+
166
+ Both path properties are emitted because the consuming policies split on
167
+ this point: enforce-evidence-locations and the two batch-budget hooks
168
+ evaluated BOTH sides of a rename before this refactor, while the purity
169
+ and checkpoint hooks evaluated only the resulting file. Carrying both
170
+ keeps every consuming policy's inputs identical to the pre-fix
171
+ behaviour.
172
+
173
+ Direct-mapped tool_input (anything carrying an explicit file_path) is
174
+ copied property-for-property, because the pre-fix adapters returned the
175
+ raw tool_input object and downstream policies read fields such as
176
+ content, old_string, and new_string from it.
177
+
178
+ .PARAMETER Payload
179
+ The object returned by ConvertFrom-CodexPreToolUsePayload.
180
+
181
+ .PARAMETER ResolveUpdateContent
182
+ When present, an apply_patch Update is reconstructed against the on-disk
183
+ source file so the caller sees complete post-patch content. Only the two
184
+ checkpoint hooks need this, and it applies ONLY to paths matching
185
+ -GovernedPath.
186
+
187
+ .PARAMETER GovernedPath
188
+ The repository-relative path the caller governs, matched with the same
189
+ '(^|/)<path>$' anchoring the checkpoint hooks use. When
190
+ -ResolveUpdateContent is supplied, an Update touching a path outside this
191
+ value yields NO record at all, so a patch that merely happens to touch
192
+ other files allows instead of failing. That closes the latent defect in
193
+ which any unreadable update source made the hook exit 2 even when it
194
+ governed none of the patched files.
195
+
196
+ .OUTPUTS
197
+ An array of file-edit records; empty when nothing maps.
198
+
199
+ .NOTES
200
+ Reconstruction failure for a GOVERNED path yields a record with empty
201
+ content rather than a throw. Empty content is the signal each checkpoint
202
+ policy already treats as an un-validatable checkpoint and fails closed
203
+ on, so a governed reconstruction failure stays a deny and never becomes
204
+ exit 2.
205
+ #>
206
+ [CmdletBinding()]
207
+ [OutputType([object[]])]
208
+ param(
209
+ [Parameter(Mandatory)]
210
+ [AllowNull()]
211
+ $Payload,
212
+
213
+ [Parameter()]
214
+ [switch] $ResolveUpdateContent,
215
+
216
+ [Parameter()]
217
+ [AllowEmptyString()]
218
+ [string] $GovernedPath = ''
219
+ )
220
+
221
+ $records = [System.Collections.Generic.List[object]]::new()
222
+
223
+ if ($null -eq $Payload -or $null -eq $Payload.tool_input) {
224
+ return $records.ToArray()
225
+ }
226
+
227
+ # Admission is by tool name only. An unadmitted name is not an error: the
228
+ # caller allows, because the matcher decides what reaches the hook.
229
+ $toolName = [string]$Payload.tool_name
230
+ if ($script:CodexAdmittedToolNames -notcontains $toolName) {
231
+ return $records.ToArray()
232
+ }
233
+
234
+ $toolInput = $Payload.tool_input
235
+ $toolInputProperties = @($toolInput.PSObject.Properties.Name)
236
+
237
+ # Direct mapping first: any tool_input carrying a usable file_path maps
238
+ # without patch parsing, which is how every pre-fix adapter behaved for
239
+ # Edit-shaped and Write-shaped input regardless of tool name.
240
+ if ($toolInputProperties -contains 'file_path') {
241
+ $filePath = [string]$toolInput.file_path
242
+ if ([string]::IsNullOrWhiteSpace($filePath)) {
243
+ return $records.ToArray()
244
+ }
245
+
246
+ # Copy every supplied field so downstream policies keep reading the same
247
+ # properties they read from the raw tool_input before this refactor.
248
+ $record = [ordered]@{}
249
+ foreach ($property in $toolInput.PSObject.Properties) {
250
+ $record[$property.Name] = $property.Value
251
+ }
252
+ $record['file_path'] = $filePath
253
+ $record['source_path'] = $filePath
254
+ $record['operation'] = $toolName
255
+
256
+ $records.Add([pscustomobject]$record)
257
+ return $records.ToArray()
258
+ }
259
+
260
+ if ($toolInputProperties -notcontains 'command') {
261
+ return $records.ToArray()
262
+ }
263
+
264
+ $command = [string]$toolInput.command
265
+ if ([string]::IsNullOrWhiteSpace($command)) {
266
+ return $records.ToArray()
267
+ }
268
+
269
+ $fileMatches = [regex]::Matches($command, $script:CodexApplyPatchFileRegex)
270
+ if ($fileMatches.Count -eq 0) {
271
+ return $records.ToArray()
272
+ }
273
+
274
+ # Build one record per file the patch touches, resolving rename destinations
275
+ # and per-operation content so each consuming policy receives the same shape
276
+ # its own adapter produced before extraction.
277
+ foreach ($fileMatch in $fileMatches) {
278
+ $sourcePath = ([string]$fileMatch.Groups['path'].Value).Trim()
279
+ $operation = [string]$fileMatch.Groups['operation'].Value
280
+ $body = ([string]$fileMatch.Groups['body'].Value) -replace '\r\n', "`n"
281
+
282
+ $targetPath = $sourcePath
283
+ $moveMatch = [regex]::Match($body, $script:CodexApplyPatchMoveRegex)
284
+ if ($moveMatch.Success) {
285
+ $targetPath = ([string]$moveMatch.Groups['path'].Value).Trim()
286
+ }
287
+
288
+ # Content derivation is per operation. Delete yields empty content, which
289
+ # is the deletion signal the checkpoint policies fail closed on. Add
290
+ # yields the added lines. Update yields the added lines unless the caller
291
+ # asked for on-disk reconstruction.
292
+ $content = ''
293
+ if ($operation -eq 'Add') {
294
+ $content = ConvertTo-CodexAddedLineText -Body $body
295
+ } elseif ($operation -eq 'Update') {
296
+ if (-not $ResolveUpdateContent) {
297
+ $content = ConvertTo-CodexAddedLineText -Body $body
298
+ } else {
299
+ $isGoverned = (Test-CodexGovernedPath -Path $sourcePath -GovernedPath $GovernedPath) -or
300
+ (Test-CodexGovernedPath -Path $targetPath -GovernedPath $GovernedPath)
301
+ if (-not $isGoverned) {
302
+ # Ungoverned update: emit nothing so the caller allows instead
303
+ # of reading an unrelated file it does not govern.
304
+ continue
305
+ }
306
+ $content = Resolve-CodexUpdatedFileContent -SourcePath $sourcePath -Body $body
307
+ }
308
+ }
309
+
310
+ $records.Add([pscustomobject]@{
311
+ file_path = $targetPath
312
+ source_path = $sourcePath
313
+ operation = $operation
314
+ content = $content
315
+ })
316
+ }
317
+
318
+ return $records.ToArray()
319
+ }
320
+
321
+ function ConvertTo-CodexAddedLineText {
322
+ <#
323
+ .SYNOPSIS
324
+ INTERNAL. Returns the added ('+') lines of an apply_patch file body.
325
+
326
+ .PARAMETER Body
327
+ The LF-normalized body of one apply_patch file section.
328
+
329
+ .OUTPUTS
330
+ The added lines joined with LF, or an empty string when there are none.
331
+
332
+ .NOTES
333
+ '+++' lines are unified-diff headers, not content, and are excluded. LF
334
+ is the join character because the checkpoint policies compare against
335
+ LF-normalized file text and the purity patterns are newline-agnostic.
336
+ #>
337
+ [CmdletBinding()]
338
+ [OutputType([string])]
339
+ param(
340
+ [Parameter(Mandatory)]
341
+ [AllowEmptyString()]
342
+ [string] $Body
343
+ )
344
+
345
+ # Collect the inserted lines in order, dropping the '+' marker itself.
346
+ $addedLines = foreach ($line in ($Body -split "`n")) {
347
+ if ($line.StartsWith('+') -and -not $line.StartsWith('+++')) {
348
+ $line.Substring(1)
349
+ }
350
+ }
351
+
352
+ return ($addedLines -join "`n")
353
+ }
354
+
355
+ function Test-CodexGovernedPath {
356
+ <#
357
+ .SYNOPSIS
358
+ INTERNAL. Returns $true when a patched path is the caller's governed path.
359
+
360
+ .PARAMETER Path
361
+ The path taken from an apply_patch file marker.
362
+
363
+ .PARAMETER GovernedPath
364
+ The repository-relative path the caller governs; empty governs nothing.
365
+
366
+ .OUTPUTS
367
+ $true when the normalized path equals the governed path or ends with it
368
+ on a segment boundary. Uses the same '(^|/)<path>$' anchoring as the
369
+ checkpoint hooks' Test-IsCheckpointPath, so absolute and relative forms
370
+ both match.
371
+ #>
372
+ [CmdletBinding()]
373
+ [OutputType([bool])]
374
+ param(
375
+ [Parameter(Mandatory)]
376
+ [AllowEmptyString()]
377
+ [string] $Path,
378
+
379
+ [Parameter(Mandatory)]
380
+ [AllowEmptyString()]
381
+ [string] $GovernedPath
382
+ )
383
+
384
+ if ([string]::IsNullOrWhiteSpace($GovernedPath) -or [string]::IsNullOrWhiteSpace($Path)) {
385
+ return $false
386
+ }
387
+
388
+ $normalizedPath = $Path -replace '\\', '/'
389
+ $normalizedGoverned = ($GovernedPath -replace '\\', '/').Trim('/')
390
+ return ($normalizedPath -match "(^|/)$([regex]::Escape($normalizedGoverned))$")
391
+ }
392
+
393
+ function Resolve-CodexUpdatedFileContent {
394
+ <#
395
+ .SYNOPSIS
396
+ INTERNAL. Reconstructs post-patch content for a governed Update.
397
+
398
+ .DESCRIPTION
399
+ Reads the source file, then applies each hunk in memory. Nothing is
400
+ written to disk. Returns an empty string when the source cannot be read
401
+ or a hunk does not apply, because empty content is the signal the
402
+ governed policies already fail closed on. This function never throws, so
403
+ a governed reconstruction failure becomes a deny rather than exit 2.
404
+
405
+ .PARAMETER SourcePath
406
+ The path to read the pre-patch content from.
407
+
408
+ .PARAMETER Body
409
+ The LF-normalized body of the Update file section.
410
+
411
+ .OUTPUTS
412
+ The reconstructed content, or an empty string on any failure.
413
+ #>
414
+ [CmdletBinding()]
415
+ [OutputType([string])]
416
+ param(
417
+ [Parameter(Mandatory)]
418
+ [AllowEmptyString()]
419
+ [string] $SourcePath,
420
+
421
+ [Parameter(Mandatory)]
422
+ [AllowEmptyString()]
423
+ [string] $Body
424
+ )
425
+
426
+ if (-not (Test-Path -LiteralPath $SourcePath -PathType Leaf)) {
427
+ return ''
428
+ }
429
+
430
+ try {
431
+ $content = (Get-Content -Raw -LiteralPath $SourcePath) -replace '\r\n', "`n"
432
+ } catch {
433
+ Write-Verbose "Unable to read update source '$SourcePath': $($_.Exception.Message)"
434
+ return ''
435
+ }
436
+
437
+ $hunks = @([regex]::Split($Body, $script:CodexApplyPatchHunkRegex) | Where-Object { $_ -match '\S' })
438
+
439
+ # Apply each hunk by locating its pre-image and substituting its post-image.
440
+ # A hunk whose pre-image is absent means the patch does not apply to this
441
+ # source, so reconstruction fails closed with empty content.
442
+ foreach ($hunk in $hunks) {
443
+ $oldLines = [System.Collections.Generic.List[string]]::new()
444
+ $newLines = [System.Collections.Generic.List[string]]::new()
445
+
446
+ # Classify each hunk line into the pre-image, the post-image, or both.
447
+ foreach ($line in ($hunk -split "`n")) {
448
+ if ($line -match '^\*\*\* (?:Move to|End of File)') {
449
+ continue
450
+ }
451
+ if ($line.StartsWith('+') -and -not $line.StartsWith('+++')) {
452
+ $newLines.Add($line.Substring(1))
453
+ } elseif ($line.StartsWith('-') -and -not $line.StartsWith('---')) {
454
+ $oldLines.Add($line.Substring(1))
455
+ } elseif ($line.StartsWith(' ')) {
456
+ $oldLines.Add($line.Substring(1))
457
+ $newLines.Add($line.Substring(1))
458
+ } else {
459
+ $oldLines.Add($line)
460
+ $newLines.Add($line)
461
+ }
462
+ }
463
+
464
+ $oldText = $oldLines -join "`n"
465
+ $newText = $newLines -join "`n"
466
+ $index = $content.IndexOf($oldText, [System.StringComparison]::Ordinal)
467
+ if ($index -lt 0) {
468
+ return ''
469
+ }
470
+ $content = $content.Substring(0, $index) + $newText + $content.Substring($index + $oldText.Length)
471
+ }
472
+
473
+ return $content
474
+ }