diff --git a/.claude/agents/atomic-executor.md b/.claude/agents/atomic-executor.md index 222eaf00..5588b831 100644 --- a/.claude/agents/atomic-executor.md +++ b/.claude/agents/atomic-executor.md @@ -15,7 +15,7 @@ tools: - "Bash(npx prettier *)" - "Bash(npx eslint *)" - "Bash(npx tsc *)" - - "Bash(npx vitest *)" + - "Bash(npx jest *)" - "Bash(pwsh *)" - "Bash(git *)" - "mcp__drm-copilot__run_poshqc_format" @@ -76,7 +76,7 @@ For each task: Use the scoped tool patterns for quality gates: - **Python**: `poetry run black`, `poetry run ruff`, `poetry run pyright`, `poetry run pytest` -- **TypeScript**: `npx prettier`, `npx eslint`, `npx tsc`, `npx vitest` +- **TypeScript**: `npx prettier`, `npx eslint`, `npx tsc`, `npx jest` - **PowerShell**: MCP server functions (`mcp__drm-copilot__run_poshqc_format`, `mcp__drm-copilot__run_poshqc_analyze`, `mcp__drm-copilot__run_poshqc_test`, `mcp__drm-copilot__run_poshqc_analyze_autofix`) - **Git**: `git diff`, `git status`, `git log` diff --git a/.claude/hooks/validate-orchestrator-output.ps1 b/.claude/hooks/validate-orchestrator-output.ps1 index ed732eee..e3e317d2 100644 --- a/.claude/hooks/validate-orchestrator-output.ps1 +++ b/.claude/hooks/validate-orchestrator-output.ps1 @@ -165,9 +165,15 @@ function Invoke-RoutingContractValidation { string is unchanged for every existing caller of this hook. Returns a hashtable with keys: - - HasErrors: $true when the validator reported a non-zero exit or - produced any error text; $false when clean. - - ErrorText: the validator's combined output text (empty on success). + - HasErrors: $true only when the validator reported a non-zero exit + code; $false when it exited 0. The exit code is the sole + discriminator, because the validator prints its success + line to stdout on a clean pass and the default Invoker + captures with 2>&1, so output text is present on success. + - ErrorText: the validator's combined captured output text, carried + through unchanged: the error lines on a failure, and the + success line (Python CLI) or empty (portable fallback) + on a clean pass. #> [CmdletBinding()] [OutputType([hashtable])] @@ -219,9 +225,11 @@ function Invoke-RoutingContractValidation { $outputText = ([string]$result.Output).Trim() } - # The validator signals a routing-contract failure either through a non-zero - # exit code or through emitted error text; either condition blocks DONE. - $hasErrors = ($exitCode -ne 0) -or (-not [string]::IsNullOrWhiteSpace($outputText)) + # The exit code is the complete failure discriminator: the validator prints every + # error to stderr and returns non-zero, and prints its success line to stdout and + # returns 0. Because the default invoker captures with 2>&1, the success line lands + # in $outputText on a clean pass, so output text must not influence this decision. + $hasErrors = ($exitCode -ne 0) return @{ HasErrors = $hasErrors; ErrorText = $outputText } } diff --git a/.claude/lib/model-routing/ModelRouting.psm1 b/.claude/lib/model-routing/ModelRouting.psm1 index 464aac4c..a1c06c51 100644 --- a/.claude/lib/model-routing/ModelRouting.psm1 +++ b/.claude/lib/model-routing/ModelRouting.psm1 @@ -11,8 +11,9 @@ - Resolve-DelegationModel port of scripts/dev_tools/resolve_delegation_model.py Both functions are pure and deterministic: they read no file at runtime and - encode only the fixed band ordering, the base complexity-to-model table, the - preferred overlay, and the disabled-mode clamp as module-scope constants. + encode only the fixed band ordering, the floor-signal name set, the base + complexity-to-model table, the preferred overlay, and the disabled-mode clamp + as module-scope constants. Those literals are pinned to config/orchestration-routing.json (model_policy / model_budget) by a static config-parity Pester test, and the Python modules remain the validator's authoritative reference. This module is one half of a @@ -29,6 +30,20 @@ $script:BAND_ORDER = @('C1', 'C2', 'C3', 'C4') # The lowest band, returned when no floor signal is present (LOWEST_BAND). $script:LOWEST_BAND = 'C1' +# The catalog signal names flagged "floor": true in model_policy.complexity. Only +# a signal named here contributes a floor candidate; a "floor": false name and an +# unknown name each contribute nothing. Hard-coded (never read from disk) because +# this module is pushed down to consumer repositories that do not ship +# config/orchestration-routing.json; a static parity Pester test pins this set to +# the config's "floor": true entries. Mirrors FLOOR_SIGNAL_NAMES in +# scripts/dev_tools/compute_complexity_floor.py. +$script:FLOOR_SIGNAL_NAMES = @( + 'classifier_or_model_logic', + 'auth_or_token_handling', + 'concurrency_or_ordering', + 'cross_module_contract_change' +) + # Every present floor signal contributes this uniform candidate band, per the # model_policy.complexity contract (each [floor] signal contributes C3). $script:FLOOR_CANDIDATE_BAND = 'C3' @@ -78,21 +93,22 @@ function Get-ComplexityFloor { .DESCRIPTION Faithful PowerShell port of compute_complexity_floor (scripts/dev_tools/compute_complexity_floor.py). Returns the deterministic - lower-bound complexity band implied by the set of present floor signals: - each present floor signal contributes a candidate band of C3, the floor is - the maximum triggered candidate band, and the floor never exceeds C3 - (C4 is never floor-forced). With no floor signal present the floor is the - lowest band C1. The function is pure: it reads no file and does not mutate - its input, and the result is independent of input ordering. + lower-bound complexity band implied by the recorded signal names: the + function intersects the input with FLOOR_SIGNAL_NAMES, each surviving + [floor] signal contributes a candidate band of C3, the floor is the maximum + triggered candidate band, and the floor never exceeds C3 (C4 is never + floor-forced). With no floor signal present the floor is the lowest band C1. + The function is pure: it reads no file and does not mutate its input, and + the result is independent of input ordering. .PARAMETER SignalsPresent - The names of the present signals flagged [floor] in the - model_policy.complexity catalog. Every element is treated as a triggered - floor signal contributing the candidate band C3. An empty collection means - no floor signal is present. + The full set of recorded signal names, as written to the checkpoint's + signals_present[] array. The caller does not pre-filter: names outside + FLOOR_SIGNAL_NAMES ("floor": false catalog names and unknown names alike) + contribute nothing. An empty collection means no floor signal is present. .OUTPUTS - System.String. The floor band: C1 when no floor signal is present, + System.String. The floor band: C1 when no recorded name is a floor signal, otherwise the maximum triggered candidate band clamped to at most C3. C4 is never returned. #> @@ -104,9 +120,13 @@ function Get-ComplexityFloor { [string[]] $SignalsPresent ) + # Keep only the recorded names that are floor signals. A "floor": false catalog + # name and an unknown name both drop out here and contribute no candidate band. + $triggered = @($SignalsPresent | Where-Object { $script:FLOOR_SIGNAL_NAMES -contains $_ }) + # With no present floor signal there is no candidate band to raise the floor # above the lowest band, so the floor is C1 (mirrors the empty-input guard). - if (-not $SignalsPresent -or $SignalsPresent.Count -eq 0) { + if ($triggered.Count -eq 0) { return $script:LOWEST_BAND } diff --git a/.claude/lib/orchestrator-state/OrchestratorState.psm1 b/.claude/lib/orchestrator-state/OrchestratorState.psm1 index 7d341bd5..9d75faeb 100644 --- a/.claude/lib/orchestrator-state/OrchestratorState.psm1 +++ b/.claude/lib/orchestrator-state/OrchestratorState.psm1 @@ -14,7 +14,7 @@ checkpoint-presence checks (required keys, step-status validity, blocked_reason validity) from `scripts/dev_tools/validate_orchestrator_state.py`. The base constants below are pinned to `REQUIRED_STATE_KEYS`, `STEP_STATUS_KEYS`, - `VALID_STEP_STATUS`, and `VALID_BLOCKED_REASONS` in that validator. + `VALID_STEP_STATUS`, `VALID_BLOCKED_REASONS`, and `STEP_SPECIFIC_EXTRA_STATUS`. Every public function FAILS CLOSED: a missing checkpoint file, invalid JSON, a missing required key, an invalid step status, or an unmet readiness condition all @@ -83,6 +83,15 @@ $script:VALID_STEP_STATUS = @( 'completed' ) +# Per-key additive step-status vocabulary layered on VALID_STEP_STATUS: each value +# below is valid only on its owning key and is still rejected on every other step +# key. Pinned to STEP_SPECIFIC_EXTRA_STATUS in +# scripts/dev_tools/_orchestrator_state_step_status.py. +$script:STEP_SPECIFIC_EXTRA_STATUS = @{ + step6_status = @('blocked_remediation_loop_limit') + step9_status = @('passed', 'failed_remediation_required', 'blocked_ci_loop_limit') +} + # The allowed blocked_reason vocabulary. Pinned to VALID_BLOCKED_REASONS in the # primary validator. $script:VALID_BLOCKED_REASONS = @( @@ -226,11 +235,11 @@ function Get-OrchestratorStateBasePresenceError { Return the base checkpoint-presence errors, mirroring the primary validator. .DESCRIPTION Private base check. Emits one error string per missing required key, one per - step5_status..step10_status value outside VALID_STEP_STATUS, and one when - blocked_reason is present with a value outside VALID_BLOCKED_REASONS. This - mirrors the base block of scripts/dev_tools/validate_orchestrator_state.py - (required keys, step-status validity, blocked_reason validity) that runs - before any mode-specific gate. + step5_status..step10_status value outside VALID_STEP_STATUS and that key's + STEP_SPECIFIC_EXTRA_STATUS set, and one when blocked_reason is present with a + value outside VALID_BLOCKED_REASONS. This mirrors the base block of + scripts/dev_tools/validate_orchestrator_state.py (required keys, step-status + validity, blocked_reason validity) that runs before any mode-specific gate. .PARAMETER State The parsed checkpoint PSCustomObject. .OUTPUTS @@ -254,12 +263,16 @@ function Get-OrchestratorStateBasePresenceError { } } - # Every present step status must be a member of the allowed vocabulary; an absent - # step key contributes no error (mirrors the primary validator's None guard). + # Every present step status must be a member of the shared vocabulary or of that + # key's additive extra set; an absent step key contributes no error (mirrors the + # primary validator's None guard). foreach ($key in $script:STEP_STATUS_KEYS) { $field = Get-OrchestratorStateField -State $State -Name $key + $extra = @() + if ($script:STEP_SPECIFIC_EXTRA_STATUS.ContainsKey($key)) { $extra = @($script:STEP_SPECIFIC_EXTRA_STATUS[$key]) } if ($field.Present -and $null -ne $field.Value -and - ($script:VALID_STEP_STATUS -notcontains [string]$field.Value)) { + ($script:VALID_STEP_STATUS -notcontains [string]$field.Value) -and + ($extra -notcontains [string]$field.Value)) { $errors.Add("Checkpoint has invalid $key`: $($field.Value)") } } @@ -279,12 +292,11 @@ function Get-OrchestratorStatePrCreationReadinessError { .SYNOPSIS Return the PR-creation-readiness errors, parity with the Python reference. .DESCRIPTION - Private readiness check mirroring - validate_orchestrator_state_pr_creation_readiness in - _orchestrator_state_pr_creation_readiness.py: steps 5-8 must not be - pending/blocked; blocked_reason must be `none` or absent; and the - local_execution_overrides / delegation_bypasses lists must be empty when - present. It does not enforce completion, CI, PR, or routing-contract gates. + Private readiness check mirroring validate_orchestrator_state_pr_creation_readiness in + _orchestrator_state_pr_creation_readiness.py: steps 5-8 must not be pending, blocked, or + blocked_remediation_loop_limit; blocked_reason must be `none` or absent; and the + local_execution_overrides / delegation_bypasses lists must be empty when present. It does + not enforce completion, CI, PR, or routing-contract gates. .PARAMETER State The parsed checkpoint PSCustomObject. .OUTPUTS @@ -299,11 +311,11 @@ function Get-OrchestratorStatePrCreationReadinessError { $errors = [System.Collections.Generic.List[string]]::new() - # Reject an upstream step recorded as pending or blocked; steps 5-8 must have - # finished before the first PR of a branch is created. + # Reject an upstream step recorded as pending, blocked, or blocked_remediation_loop_limit; steps + # 5-8 must have finished before the first PR of a branch is created. foreach ($key in $script:PR_CREATION_READY_STEP_KEYS) { $field = Get-OrchestratorStateField -State $State -Name $key - if ($field.Present -and ($field.Value -eq 'pending' -or $field.Value -eq 'blocked')) { + if ($field.Present -and (@('pending', 'blocked', 'blocked_remediation_loop_limit') -contains $field.Value)) { $errors.Add("Checkpoint PR-creation readiness validation failed: $key is $($field.Value).") } } diff --git a/.claude/rules/general-code-change.md b/.claude/rules/general-code-change.md index 8692c6eb..69d31ef8 100644 --- a/.claude/rules/general-code-change.md +++ b/.claude/rules/general-code-change.md @@ -36,7 +36,7 @@ Run the full seven-stage toolchain in this exact order and repeat until all stag 2. **Linting** (e.g., Ruff, ESLint, PSScriptAnalyzer, .NET analyzers) 3. **Type checking** (e.g., Pyright, TSC, nullable analysis; skip for PowerShell) 4. **Architecture-boundary tests** (e.g., dependency-cruiser, NetArchTest.Rules) -5. **Unit tests** (e.g., Pytest, Vitest, MSTest, Pester) including property-based tests where applicable per `quality-tiers.md` +5. **Unit tests** (e.g., Pytest, Jest, MSTest, Pester) including property-based tests where applicable per `quality-tiers.md` 6. **Contract / schema compatibility checks** (e.g., oasdiff, schema-snapshot diff) 7. **Integration tests** diff --git a/.claude/rules/general-unit-test.md b/.claude/rules/general-unit-test.md index 45d85219..5137360c 100644 --- a/.claude/rules/general-unit-test.md +++ b/.claude/rules/general-unit-test.md @@ -37,7 +37,7 @@ The correct response to a file that contains untestable lines is to refactor it **Permitted `exclude` entries** (non-production paths only): - Build output directories: `dist/**`, `lib/**`, `lib-amd/**`. - Test files and test infrastructure: `**/*.test.ts`, `tests/**`, `src/test-support/**`. -- Config files that are not production code: `vitest.config.ts`, `eslint.config.mjs`, `.dependency-cruiser.cjs`, `webpack.config.js`. +- Config files that are not production code: `jest.config.cjs`, `eslint.config.mjs`, `.dependency-cruiser.cjs`, `webpack.config.js`. - `node_modules/**`. **Prohibited `exclude` entries:** @@ -102,4 +102,4 @@ All test code must be deterministic. The following infrastructure requirements a - **Controllable clock** — use a `Clock` interface (TypeScript) or `TimeProvider` (.NET) injected into code under test. Do not read wall-clock time directly in production code under test. - **Seeded RNG** — randomness must be supplied via a seedable interface; on test failure the seed must be printed so the failure is reproducible. - **Banned APIs in test code** — `setTimeout`, `Thread.Sleep`, `Task.Delay`, real wall-clock waits, and `Date.now()` outside the clock interface are prohibited in tests. -- **Virtual scheduler / fake timers / `FakeTimeProvider`** — async tests must use the framework's fake-timer facility (`vi.useFakeTimers()` for Vitest, `FakeTimeProvider` for .NET) to advance simulated time deterministically. +- **Virtual scheduler / fake timers / `FakeTimeProvider`** — async tests must use the framework's fake-timer facility (`jest.useFakeTimers()` for Jest, `FakeTimeProvider` for .NET) to advance simulated time deterministically. diff --git a/.codex/config.toml b/.codex/config.toml index 20745d24..7fc48f10 100644 --- a/.codex/config.toml +++ b/.codex/config.toml @@ -2,7 +2,7 @@ default_permissions = ":danger-full-access" [mcp_servers.drm-copilot] command = "npx" -args = ["-y", "@danmoisan/drm-copilot-mcp@1.0.18"] +args = ["-y", "@danmoisan/drm-copilot-mcp@1.0.20"] required = true enabled_tools = [ "collect_commit_context", diff --git a/.codex/hooks/codex-pretooluse-file-mapping.ps1 b/.codex/hooks/codex-pretooluse-file-mapping.ps1 new file mode 100644 index 00000000..e8b1672e --- /dev/null +++ b/.codex/hooks/codex-pretooluse-file-mapping.ps1 @@ -0,0 +1,474 @@ +<# +.SYNOPSIS + Shared Codex PreToolUse transport parsing and tool_input-to-file-edit mapping. + +.DESCRIPTION + Dot-sourced by every hook registered under the .codex/config.toml matcher + ^(apply_patch|Edit|Write)$. It provides the two transport concerns those + hooks previously duplicated with drift: + + - ConvertFrom-CodexPreToolUsePayload: validates and parses the single JSON + object a Codex PreToolUse hook receives on stdin. It throws only for + genuinely un-processable input, and every message it throws begins with + the caller-supplied hook name so each handler's stderr names itself. + + - ConvertTo-CodexFileEditInput: maps a parsed payload to zero or more + file-edit records for the admitted tool names apply_patch, Edit, and + Write. Well-formed input that names nothing the caller governs maps to an + EMPTY array, which callers translate to allow (exit 0, no stdout) rather + than to a transport failure. + + Separating these concerns is required because enforce-checkpoint-monotonic.ps1 + and enforce-completion-consistency.ps1 are already near the 500-line cap and + cannot absorb inline mapping logic. + + PUBLIC SURFACE. Consuming hooks call exactly the two functions named above + and nothing else. ConvertTo-CodexAddedLineText, Test-CodexGovernedPath, and + Resolve-CodexUpdatedFileContent are INTERNAL helpers of + ConvertTo-CodexFileEditInput; they exist only because the repository's code + standards require long branching logic to be factored into small, focused + functions rather than inlined, and no hook calls them directly. + + This script performs no policy evaluation. Each consuming hook keeps its own + allow/deny policy functions unchanged and applies them to the records + returned here. + +.NOTES + Compatible with PowerShell 7+. + + Entrypoint-free by design: this file contains only script-scoped constants + and function definitions, so dot-sourcing it has no side effects and no + exit-code semantics. The precedent is enforce-completion-helpers.ps1, which + is likewise dot-sourced and carries no entrypoint. Consequently this file + never reads standard input and never reads any legacy Claude environment + variable; reading the payload is the calling hook's responsibility, and the + Pester parity suite asserts that no legacy environment read appears here. +#> +[CmdletBinding()] +param() + +# Tool names the ^(apply_patch|Edit|Write)$ PreToolUse matcher admits. Any other +# well-formed tool name maps to no records, which the caller treats as allow. +$script:CodexAdmittedToolNames = @('apply_patch', 'Edit', 'Write') + +# Splits an apply_patch command into one match per touched file. The lookahead +# terminates each file body at the next file marker or at '*** End Patch', so a +# multi-file patch yields one match per file. Lifted verbatim from the +# per-handler implementations this module replaces. +$script:CodexApplyPatchFileRegex = '(?ms)^\*\*\* (?Add|Update|Delete) File:\s*(?.+?)\r?\n(?.*?)(?=^\*\*\* (?:(?:Add|Update|Delete) File:|End Patch)\s*|\z)' + +# Locates a rename destination inside a single file body. +$script:CodexApplyPatchMoveRegex = '(?m)^\*\*\* Move to:\s*(?.+?)\s*$' + +# Splits an apply_patch Update body into hunks on its @@ headers. +$script:CodexApplyPatchHunkRegex = '(?m)^@@[^\r\n]*\r?\n' + +function ConvertFrom-CodexPreToolUsePayload { + <# + .SYNOPSIS + Parses and validates one Codex PreToolUse stdin payload. + + .DESCRIPTION + Converts the raw JSON string a PreToolUse hook read from stdin into an + object, throwing only when the input cannot be processed at all. The + caller converts a throw into exit 2 with the message on stderr. + + This function deliberately performs NO tool-name assertion. Admission is + the matcher's job in .codex/config.toml, and narrowing it here is the + defect that made every Edit and Write payload exit 2. Tool-name routing + belongs to ConvertTo-CodexFileEditInput, which maps unadmitted names to + no records so the caller allows. + + .PARAMETER PayloadRaw + The raw stdin text. Empty, whitespace-only, and null are all rejected. + AllowEmptyString is required so the explicit, hook-named error below runs + instead of a generic parameter-binding failure whose message would not + name the hook. + + .PARAMETER HookName + The calling hook's name. Every thrown message begins with this value so + a handler that shares this module still reports itself in its stderr. + + .PARAMETER RequireSessionId + When present, a missing, empty, or whitespace-only session_id is also + rejected. Only the batch-budget hooks need this, because they key + per-session state by session_id. + + .OUTPUTS + The parsed payload object. + + .NOTES + Throws for exactly four conditions and nothing else: empty or + whitespace-only input, invalid JSON, missing or null tool_input, and + (only under -RequireSessionId) a missing or empty session_id. + #> + [CmdletBinding()] + [OutputType([object])] + param( + [Parameter(Mandatory)] + [AllowNull()] + [AllowEmptyString()] + [string] $PayloadRaw, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $HookName, + + [Parameter()] + [switch] $RequireSessionId + ) + + if ([string]::IsNullOrWhiteSpace($PayloadRaw)) { + throw "$HookName hook input is empty." + } + + try { + $payload = $PayloadRaw | ConvertFrom-Json -ErrorAction Stop + } catch { + throw "$HookName hook input is malformed JSON: $_" + } + + # A JSON literal such as 'null', a bare string, or a number parses without + # error but carries no tool_input, so both cases fail through one check. + if ($null -eq $payload -or + $payload.PSObject.Properties.Name -notcontains 'tool_input' -or + $null -eq $payload.tool_input) { + throw "$HookName hook input is missing tool_input." + } + + if ($RequireSessionId -and [string]::IsNullOrWhiteSpace([string]$payload.session_id)) { + throw "$HookName hook input is missing session_id." + } + + return $payload +} + +function ConvertTo-CodexFileEditInput { + <# + .SYNOPSIS + Maps a parsed Codex PreToolUse payload to zero or more file-edit records. + + .DESCRIPTION + Admits the tool names apply_patch, Edit, and Write. Every other + well-formed tool name, and every admitted tool name whose tool_input + names no file, maps to an EMPTY array so the caller allows. + + Each emitted record carries: + + file_path - the path the operation ultimately affects; for an + apply_patch rename this is the '*** Move to:' + destination, matching the pre-fix adapters. + source_path - the path the operation started from; equal to file_path + except for a rename. + operation - Add, Update, Delete, Edit, or Write. + content, old_string, new_string - whichever fields the operation has. + + Both path properties are emitted because the consuming policies split on + this point: enforce-evidence-locations and the two batch-budget hooks + evaluated BOTH sides of a rename before this refactor, while the purity + and checkpoint hooks evaluated only the resulting file. Carrying both + keeps every consuming policy's inputs identical to the pre-fix + behaviour. + + Direct-mapped tool_input (anything carrying an explicit file_path) is + copied property-for-property, because the pre-fix adapters returned the + raw tool_input object and downstream policies read fields such as + content, old_string, and new_string from it. + + .PARAMETER Payload + The object returned by ConvertFrom-CodexPreToolUsePayload. + + .PARAMETER ResolveUpdateContent + When present, an apply_patch Update is reconstructed against the on-disk + source file so the caller sees complete post-patch content. Only the two + checkpoint hooks need this, and it applies ONLY to paths matching + -GovernedPath. + + .PARAMETER GovernedPath + The repository-relative path the caller governs, matched with the same + '(^|/)$' anchoring the checkpoint hooks use. When + -ResolveUpdateContent is supplied, an Update touching a path outside this + value yields NO record at all, so a patch that merely happens to touch + other files allows instead of failing. That closes the latent defect in + which any unreadable update source made the hook exit 2 even when it + governed none of the patched files. + + .OUTPUTS + An array of file-edit records; empty when nothing maps. + + .NOTES + Reconstruction failure for a GOVERNED path yields a record with empty + content rather than a throw. Empty content is the signal each checkpoint + policy already treats as an un-validatable checkpoint and fails closed + on, so a governed reconstruction failure stays a deny and never becomes + exit 2. + #> + [CmdletBinding()] + [OutputType([object[]])] + param( + [Parameter(Mandatory)] + [AllowNull()] + $Payload, + + [Parameter()] + [switch] $ResolveUpdateContent, + + [Parameter()] + [AllowEmptyString()] + [string] $GovernedPath = '' + ) + + $records = [System.Collections.Generic.List[object]]::new() + + if ($null -eq $Payload -or $null -eq $Payload.tool_input) { + return $records.ToArray() + } + + # Admission is by tool name only. An unadmitted name is not an error: the + # caller allows, because the matcher decides what reaches the hook. + $toolName = [string]$Payload.tool_name + if ($script:CodexAdmittedToolNames -notcontains $toolName) { + return $records.ToArray() + } + + $toolInput = $Payload.tool_input + $toolInputProperties = @($toolInput.PSObject.Properties.Name) + + # Direct mapping first: any tool_input carrying a usable file_path maps + # without patch parsing, which is how every pre-fix adapter behaved for + # Edit-shaped and Write-shaped input regardless of tool name. + if ($toolInputProperties -contains 'file_path') { + $filePath = [string]$toolInput.file_path + if ([string]::IsNullOrWhiteSpace($filePath)) { + return $records.ToArray() + } + + # Copy every supplied field so downstream policies keep reading the same + # properties they read from the raw tool_input before this refactor. + $record = [ordered]@{} + foreach ($property in $toolInput.PSObject.Properties) { + $record[$property.Name] = $property.Value + } + $record['file_path'] = $filePath + $record['source_path'] = $filePath + $record['operation'] = $toolName + + $records.Add([pscustomobject]$record) + return $records.ToArray() + } + + if ($toolInputProperties -notcontains 'command') { + return $records.ToArray() + } + + $command = [string]$toolInput.command + if ([string]::IsNullOrWhiteSpace($command)) { + return $records.ToArray() + } + + $fileMatches = [regex]::Matches($command, $script:CodexApplyPatchFileRegex) + if ($fileMatches.Count -eq 0) { + return $records.ToArray() + } + + # Build one record per file the patch touches, resolving rename destinations + # and per-operation content so each consuming policy receives the same shape + # its own adapter produced before extraction. + foreach ($fileMatch in $fileMatches) { + $sourcePath = ([string]$fileMatch.Groups['path'].Value).Trim() + $operation = [string]$fileMatch.Groups['operation'].Value + $body = ([string]$fileMatch.Groups['body'].Value) -replace '\r\n', "`n" + + $targetPath = $sourcePath + $moveMatch = [regex]::Match($body, $script:CodexApplyPatchMoveRegex) + if ($moveMatch.Success) { + $targetPath = ([string]$moveMatch.Groups['path'].Value).Trim() + } + + # Content derivation is per operation. Delete yields empty content, which + # is the deletion signal the checkpoint policies fail closed on. Add + # yields the added lines. Update yields the added lines unless the caller + # asked for on-disk reconstruction. + $content = '' + if ($operation -eq 'Add') { + $content = ConvertTo-CodexAddedLineText -Body $body + } elseif ($operation -eq 'Update') { + if (-not $ResolveUpdateContent) { + $content = ConvertTo-CodexAddedLineText -Body $body + } else { + $isGoverned = (Test-CodexGovernedPath -Path $sourcePath -GovernedPath $GovernedPath) -or + (Test-CodexGovernedPath -Path $targetPath -GovernedPath $GovernedPath) + if (-not $isGoverned) { + # Ungoverned update: emit nothing so the caller allows instead + # of reading an unrelated file it does not govern. + continue + } + $content = Resolve-CodexUpdatedFileContent -SourcePath $sourcePath -Body $body + } + } + + $records.Add([pscustomobject]@{ + file_path = $targetPath + source_path = $sourcePath + operation = $operation + content = $content + }) + } + + return $records.ToArray() +} + +function ConvertTo-CodexAddedLineText { + <# + .SYNOPSIS + INTERNAL. Returns the added ('+') lines of an apply_patch file body. + + .PARAMETER Body + The LF-normalized body of one apply_patch file section. + + .OUTPUTS + The added lines joined with LF, or an empty string when there are none. + + .NOTES + '+++' lines are unified-diff headers, not content, and are excluded. LF + is the join character because the checkpoint policies compare against + LF-normalized file text and the purity patterns are newline-agnostic. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)] + [AllowEmptyString()] + [string] $Body + ) + + # Collect the inserted lines in order, dropping the '+' marker itself. + $addedLines = foreach ($line in ($Body -split "`n")) { + if ($line.StartsWith('+') -and -not $line.StartsWith('+++')) { + $line.Substring(1) + } + } + + return ($addedLines -join "`n") +} + +function Test-CodexGovernedPath { + <# + .SYNOPSIS + INTERNAL. Returns $true when a patched path is the caller's governed path. + + .PARAMETER Path + The path taken from an apply_patch file marker. + + .PARAMETER GovernedPath + The repository-relative path the caller governs; empty governs nothing. + + .OUTPUTS + $true when the normalized path equals the governed path or ends with it + on a segment boundary. Uses the same '(^|/)$' anchoring as the + checkpoint hooks' Test-IsCheckpointPath, so absolute and relative forms + both match. + #> + [CmdletBinding()] + [OutputType([bool])] + param( + [Parameter(Mandatory)] + [AllowEmptyString()] + [string] $Path, + + [Parameter(Mandatory)] + [AllowEmptyString()] + [string] $GovernedPath + ) + + if ([string]::IsNullOrWhiteSpace($GovernedPath) -or [string]::IsNullOrWhiteSpace($Path)) { + return $false + } + + $normalizedPath = $Path -replace '\\', '/' + $normalizedGoverned = ($GovernedPath -replace '\\', '/').Trim('/') + return ($normalizedPath -match "(^|/)$([regex]::Escape($normalizedGoverned))$") +} + +function Resolve-CodexUpdatedFileContent { + <# + .SYNOPSIS + INTERNAL. Reconstructs post-patch content for a governed Update. + + .DESCRIPTION + Reads the source file, then applies each hunk in memory. Nothing is + written to disk. Returns an empty string when the source cannot be read + or a hunk does not apply, because empty content is the signal the + governed policies already fail closed on. This function never throws, so + a governed reconstruction failure becomes a deny rather than exit 2. + + .PARAMETER SourcePath + The path to read the pre-patch content from. + + .PARAMETER Body + The LF-normalized body of the Update file section. + + .OUTPUTS + The reconstructed content, or an empty string on any failure. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)] + [AllowEmptyString()] + [string] $SourcePath, + + [Parameter(Mandatory)] + [AllowEmptyString()] + [string] $Body + ) + + if (-not (Test-Path -LiteralPath $SourcePath -PathType Leaf)) { + return '' + } + + try { + $content = (Get-Content -Raw -LiteralPath $SourcePath) -replace '\r\n', "`n" + } catch { + Write-Verbose "Unable to read update source '$SourcePath': $($_.Exception.Message)" + return '' + } + + $hunks = @([regex]::Split($Body, $script:CodexApplyPatchHunkRegex) | Where-Object { $_ -match '\S' }) + + # Apply each hunk by locating its pre-image and substituting its post-image. + # A hunk whose pre-image is absent means the patch does not apply to this + # source, so reconstruction fails closed with empty content. + foreach ($hunk in $hunks) { + $oldLines = [System.Collections.Generic.List[string]]::new() + $newLines = [System.Collections.Generic.List[string]]::new() + + # Classify each hunk line into the pre-image, the post-image, or both. + foreach ($line in ($hunk -split "`n")) { + if ($line -match '^\*\*\* (?:Move to|End of File)') { + continue + } + if ($line.StartsWith('+') -and -not $line.StartsWith('+++')) { + $newLines.Add($line.Substring(1)) + } elseif ($line.StartsWith('-') -and -not $line.StartsWith('---')) { + $oldLines.Add($line.Substring(1)) + } elseif ($line.StartsWith(' ')) { + $oldLines.Add($line.Substring(1)) + $newLines.Add($line.Substring(1)) + } else { + $oldLines.Add($line) + $newLines.Add($line) + } + } + + $oldText = $oldLines -join "`n" + $newText = $newLines -join "`n" + $index = $content.IndexOf($oldText, [System.StringComparison]::Ordinal) + if ($index -lt 0) { + return '' + } + $content = $content.Substring(0, $index) + $newText + $content.Substring($index + $oldText.Length) + } + + return $content +} diff --git a/.codex/hooks/enforce-epic-child-worktree-binding.ps1 b/.codex/hooks/enforce-epic-child-worktree-binding.ps1 index a1d5d38d..9d37b165 100644 --- a/.codex/hooks/enforce-epic-child-worktree-binding.ps1 +++ b/.codex/hooks/enforce-epic-child-worktree-binding.ps1 @@ -267,6 +267,18 @@ function Invoke-CodexChildGuardGit { return & git @GitArgs 2>&1 } +function Get-CodexChildGuardLiveBranch { + [CmdletBinding()] + [OutputType([string])] + param([Parameter(Mandatory)][string] $RepositoryRoot) + + $liveBranch = [string](Invoke-CodexChildGuardGit -GitArgs @('-C', $RepositoryRoot, 'branch', '--show-current')) + if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($liveBranch)) { + return '' + } + return $liveBranch.Trim() +} + if ($MyInvocation.InvocationName -eq '.') { return } @@ -308,12 +320,9 @@ try { } else { '' } - $liveBranch = [string](Invoke-CodexChildGuardGit -GitArgs @('-C', $repositoryRoot, 'branch', '--show-current')) - if ($LASTEXITCODE -ne 0) { - $liveBranch = '' - } + $liveBranch = Get-CodexChildGuardLiveBranch -RepositoryRoot $repositoryRoot $decision = Invoke-CodexEpicChildGuardDecision -PayloadRaw $payloadRaw -ReceiptRaw $receiptRaw ` - -Attestation $attestation -HookRepositoryRoot $repositoryRoot -LiveBranch $liveBranch.Trim() ` + -Attestation $attestation -HookRepositoryRoot $repositoryRoot -LiveBranch $liveBranch ` -ActualSpecSha256 $actualSpecSha256 -ActualProfileSha256 $actualProfileSha256 if ($null -ne $decision) { $decision | ConvertTo-Json -Compress -Depth 5 | Write-Output diff --git a/.codex/hooks/enforce-epic-planning-only.ps1 b/.codex/hooks/enforce-epic-planning-only.ps1 index 1206a9fb..b3eacb91 100644 --- a/.codex/hooks/enforce-epic-planning-only.ps1 +++ b/.codex/hooks/enforce-epic-planning-only.ps1 @@ -245,6 +245,28 @@ function Invoke-EpicPlanningOnlyDecision { return Get-EpicPlanningDenyDecision -Reason "tool '$toolName' is not classified for preparation mode." } +function Invoke-EpicPlanningGit { + [CmdletBinding()] + param([Parameter(Mandatory)][string[]] $GitArgs) + + return & git @GitArgs 2>$null +} + +function Get-EpicPlanningCurrentBranch { + [CmdletBinding()] + [OutputType([string])] + param([Parameter(Mandatory)][string] $RepositoryRoot) + + $currentBranch = [string](Invoke-EpicPlanningGit -GitArgs @('-C', $RepositoryRoot, 'branch', '--show-current')) + if ($LASTEXITCODE -ne 0) { + throw 'EPIC_PLANNING_ONLY_BLOCKED: current branch could not be resolved before push.' + } + if ([string]::IsNullOrWhiteSpace($currentBranch)) { + return '' + } + return $currentBranch.Trim() +} + if ($MyInvocation.InvocationName -eq '.') { return } @@ -270,11 +292,7 @@ try { } if ([string]$payload.tool_name -eq 'Bash' -and [string]$payload.tool_input.command -match '^\s*git\s+push\b') { - $currentBranch = [string](& git -C $repositoryRoot branch --show-current 2>$null) - if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($currentBranch)) { - throw 'EPIC_PLANNING_ONLY_BLOCKED: current branch could not be resolved before push.' - } - $currentBranch = $currentBranch.Trim() + $currentBranch = Get-EpicPlanningCurrentBranch -RepositoryRoot $repositoryRoot } $decision = Invoke-EpicPlanningOnlyDecision ` -PayloadRaw $payloadRaw `