diff --git a/.claude/agents/atomic-planner.md b/.claude/agents/atomic-planner.md index 3d43e7590..f73b7b66f 100644 --- a/.claude/agents/atomic-planner.md +++ b/.claude/agents/atomic-planner.md @@ -57,6 +57,8 @@ Generate plans using the atomic plan contract defined in the `atomic-plan-contra ## Preflight Validation +Before handing the plan to executor preflight, return the exact bounded line record defined in `atomic-plan-contract`, starting with `PLANNER-INTERNAL-REVIEW: PASS` and ending with the required `PREFLIGHT:` signal. The bounded record must include exactly one passing `CITATION-TO-TREE`, `AC-TRACEABILITY`, and `SCOPE-BOUNDARY` declaration; one or more current-tree `CITATION: | ` records; one complete unique `AC-INVENTORY:`; one complete `AC-MAPPING: | IMPLEMENTATION: | TESTS: | EVIDENCE: ` for every and only inventory ID; and exactly one `UNRESOLVED-GAPS: NONE`. A missing, duplicate, malformed, failed, blocked, or out-of-bounds record declaration requires `SELF-REVIEW: BLOCKED` and stops handoff. + Return the finalized plan for validation-only preflight through `atomic-executor` and preserve the same target file path across revision loops. Do not claim nested worker delegation from within planner execution. ## Output diff --git a/.claude/agents/parallel-orchestrator.md b/.claude/agents/parallel-orchestrator.md index cc63224ad..f5d6f1253 100644 --- a/.claude/agents/parallel-orchestrator.md +++ b/.claude/agents/parallel-orchestrator.md @@ -89,11 +89,12 @@ reference; it is not invoked on the destination-runtime path. Cohort recoloring batching use `compute-cohorts.sh` and `compute-concurrency-batches.sh` under the same allowlist entry. -The two `poetry run` grants remain for the repository-local paths that still need an interpreter: -the checkpoint-validator CLI fallback the skill names in its `## Parallel-Level Checkpoint` section -is invoked as `poetry run python -m`, and the drift-detection CLI likewise. Both grants stay scoped -to those two invocation forms only — not to `poetry run` as a whole — so `pytest`, `black`, `ruff`, -and every other `poetry run` subcommand remain outside the allowlist. The sibling persona +The `poetry run` grants remain for the repository-local paths that still need an interpreter. The +skill's `## Parallel-Level Checkpoint` section now validates through +`mcp__drm-copilot__validate_orchestration_artifacts`, so exactly one named consumer is left: the +drift-detection CLI, invoked as `poetry run python -m`. Each grant stays scoped to its own +`poetry run python` invocation form — not to `poetry run` as a whole — so `pytest`, `black`, +`ruff`, and every other `poetry run` subcommand remain outside the allowlist. The sibling persona `.claude/agents/parallel-planner.md` records the same destination-runtime posture. ## Startup Protocol diff --git a/.claude/agents/parallel-planner.md b/.claude/agents/parallel-planner.md index e1a691af7..d59f9c02e 100644 --- a/.claude/agents/parallel-planner.md +++ b/.claude/agents/parallel-planner.md @@ -17,6 +17,7 @@ tools: - "Bash(bash .claude/lib/bash/compute-cohorts.sh*)" - "Bash(bash .claude/lib/bash/compute-concurrency-batches.sh*)" - "Bash(bash .claude/lib/bash/validate-parallel-manifest.sh*)" + - "Bash(bash .claude/lib/bash/report-lane-assertion.sh*)" - "mcp__drm-copilot__validate_orchestration_artifacts" skills: - policy-compliance-order @@ -148,19 +149,24 @@ plan a parallel run. relation come from `.claude/lib/blast-radius/BlastRadius.psm1`: ```powershell -Import-Module .claude/lib/blast-radius/BlastRadius.psm1 -Force +$repoRoot = git rev-parse --show-toplevel +Import-Module (Join-Path $repoRoot '.claude/lib/blast-radius/BlastRadius.psm1') -Force -ErrorAction Stop ``` +The default PowerShell 5.1 execution policy blocks `Import-Module` of a `.psm1` file, so `pwsh` is +mandatory here. + The facade exports `Get-PlanPaths`, `Get-BlastRadius`, `Get-BlastRadiusFromObservedPaths`, `Test-BlastRadius`, and `Test-BlastRadiusConflict`. Its truth table is `config/blast-radius.json`, which push-down publishes alongside `.claude`. **Cohort seeding and concurrency batching — bash entry points.** The bash library is granted as -three entry-point-specific allowlist entries — `"Bash(bash .claude/lib/bash/compute-cohorts.sh*)"`, -`"Bash(bash .claude/lib/bash/compute-concurrency-batches.sh*)"`, and -`"Bash(bash .claude/lib/bash/validate-parallel-manifest.sh*)"` — one per command-line entry point. -The six sourceable libraries carry no grant because they are never invoked directly. The two -commands below require the first two of those entries: +four entry-point-specific allowlist entries — `"Bash(bash .claude/lib/bash/compute-cohorts.sh*)"`, +`"Bash(bash .claude/lib/bash/compute-concurrency-batches.sh*)"`, +`"Bash(bash .claude/lib/bash/validate-parallel-manifest.sh*)"`, and the entry for +`.claude/lib/bash/report-lane-assertion.sh` declared in this persona's `tools:` list — one per +command-line entry point. The seven sourceable libraries carry no grant because they are never +invoked directly. The two commands below require the first two of those entries: ```bash bash .claude/lib/bash/compute-cohorts.sh --keys " ..." --edges ": ..." @@ -176,6 +182,16 @@ bash .claude/lib/bash/validate-parallel-manifest.sh --print-mode bash .claude/lib/bash/validate-parallel-manifest.sh --print-max-concurrency ``` +**Lane assertion — bash entry point.** The entry-point grant for +`.claude/lib/bash/report-lane-assertion.sh` covers: + +```bash +bash .claude/lib/bash/report-lane-assertion.sh --manifest --edges ": ..." +``` + +The diagnostic is advisory only. It never blocks the run, never modifies or suppresses a derived +edge, never feeds cohort computation, and always exits 0, including when it reports disagreements. + **Python modules are the repository authority, not the runtime path.** `scripts/dev_tools/compute_blast_radius.py`, `scripts/dev_tools/parallel_cohort_computation.py`, and `scripts/dev_tools/parallel_manifest_contract.py` remain the reference implementations that the @@ -183,4 +199,5 @@ ported libraries are asserted against by shared fixture corpora. Do not invoke t destination-runtime path; cite them for their contract. The `"Bash(poetry run *)"` allowlist entry is retained for the repository-local paths that still -need it — it is not required by any step above. +need a Python interpreter. The four bash entry points above and the PowerShell blast-radius facade +run without it, so no destination-runtime step depends on that grant. diff --git a/.claude/agents/prd-feature.md b/.claude/agents/prd-feature.md index ac51255e9..a943d4714 100644 --- a/.claude/agents/prd-feature.md +++ b/.claude/agents/prd-feature.md @@ -16,6 +16,8 @@ hooks: hooks: - type: command command: pwsh -NoProfile -File .claude/hooks/validate-required-artifact-output.ps1 -AgentName prd-feature -RequiredArtifact 'spec-path|^docs/features/active/.+/spec\.md$|feature spec artifact' -RequiredArtifact 'user-story-path|^docs/features/active/.+/user-story\.md$|feature user story artifact' + - type: command + command: pwsh -NoProfile -File .claude/hooks/validate-prd-feature-output.ps1 --- # PRD Feature Agent @@ -27,12 +29,15 @@ Produce feature-document outputs for the active feature folder. - `docs/features/active//spec.md` - `docs/features/active//user-story.md` +When an approved `spec.md` acceptance criterion contains a numeric count, enumeration, or population, require the supplied research record to include complete `## Numeric Derivation Evidence`: `Complete Family`, `Exhaustive Search Scope`, `Inclusion Rules`, `Exclusion Rules`, `Primary Search Strategy or Query Expression`, `Primary Member Set`, `Primary Count`, `Cross-check Search Strategy or Query Expression`, `Cross-check Member Set`, `Cross-check Count`, and `Member-set Comparison`. The two derivations must be non-empty, independently constructed, use distinct search strategies or query expressions, independently enumerate member sets, and explicitly compare those sets. The exhaustive scope must cover the complete family, including all relevant overloads and members. Omit the numeric assertion when the record is missing, repeated, incomplete, non-exhaustive, narrow, or disagrees; equal totals, distinct query text, or equal member sets alone are insufficient. + ## Output Reporting Report the final artifact paths as: - `spec-path: docs/features/active//spec.md` - `user-story-path: docs/features/active//user-story.md` +- `research-path: docs/features/active//research/--research.md` when numeric acceptance criteria are present ## Evidence Location Invariant diff --git a/.claude/agents/task-researcher.md b/.claude/agents/task-researcher.md index 1f5fd8d55..e0f601827 100644 --- a/.claude/agents/task-researcher.md +++ b/.claude/agents/task-researcher.md @@ -64,6 +64,8 @@ The orchestrator resolves which root to use from whether an active feature folde ### 4. Requirements Mapping - Map acceptance criteria into a concrete design with proposed state model, transitions, and required file changes. +- Before a numeric count, enumeration, or population can be proposed for an approved `spec.md` acceptance criterion, add a complete `## Numeric Derivation Evidence` section. For each numeric claim, record `Complete Family`, `Exhaustive Search Scope`, `Inclusion Rules`, `Exclusion Rules`, `Primary Search Strategy or Query Expression`, `Primary Member Set`, `Primary Count`, `Cross-check Search Strategy or Query Expression`, `Cross-check Member Set`, `Cross-check Count`, and `Member-set Comparison`. +- The primary and cross-check records must both be non-empty, independently enumerate their member sets, and name distinct search strategies or query expressions. The exhaustive scope must cover the complete family, including every relevant overload or member; a single grep or query that matches only one named pattern in the declared family is insufficient even if it yields the same count. Explicitly compare the normalized primary and cross-check member sets before proposing the numeric assertion. Withhold the assertion if either record is missing, incomplete, duplicated, non-exhaustive, narrow, or disagrees. ### 5. Testing Implications @@ -75,6 +77,7 @@ The orchestrator resolves which root to use from whether an active feature folde - Ground all findings in verified evidence. - Keep discussion of non-selected approaches brief. - Do not claim nested worker delegation. +- Do not present a numeric `spec.md` acceptance criterion when the required numeric derivation evidence is absent, incomplete, or has disagreeing counts. ## Evidence Location Invariant diff --git a/.claude/hooks/enforce-orchestration-preimplementation-gate-modes.ps1 b/.claude/hooks/enforce-orchestration-preimplementation-gate-modes.ps1 index 3a94482b3..f1be5007c 100644 --- a/.claude/hooks/enforce-orchestration-preimplementation-gate-modes.ps1 +++ b/.claude/hooks/enforce-orchestration-preimplementation-gate-modes.ps1 @@ -267,7 +267,7 @@ function Find-OrchestrationDelegationIssueNumber { if (-not $Prompt) { return $null } - $keyed = [regex]::Match($Prompt, 'issue[_-]?num(?:ber)?\s*[:=]\s*#?(\d+)', 'IgnoreCase') + $keyed = [regex]::Match($Prompt, 'issue(?:[_-]?num(?:ber)?|\s+number)\s*[:=]\s*#?(\d+)', 'IgnoreCase') if ($keyed.Success) { return $keyed.Groups[1].Value } $hashForm = [regex]::Match($Prompt, '(?:^|\s)#(\d+)\b') if ($hashForm.Success) { return $hashForm.Groups[1].Value } @@ -344,14 +344,17 @@ function Find-OrchestrationModeRecord { ) if ($null -eq $Records) { return $null } - foreach ($record in @($Records)) { - if ($null -eq $record) { continue } - if ($TargetFolder) { + if ($TargetFolder) { + foreach ($record in @($Records)) { + if ($null -eq $record) { continue } $folder = Get-OrchestrationModeString -Value $record -Name 'feature_folder' $basename = Get-OrchestrationModeFolderBasename -Path $folder if ($basename -and $basename -eq $TargetFolder) { return $record } } - if ($IssueNumber) { + } + if ($IssueNumber) { + foreach ($record in @($Records)) { + if ($null -eq $record) { continue } $issue = Get-OrchestrationModeString -Value $record -Name 'issue_num' if ($issue -and $issue -eq $IssueNumber) { return $record } } diff --git a/.claude/hooks/enforce-powershell-batch-budget.ps1 b/.claude/hooks/enforce-powershell-batch-budget.ps1 index 21945684a..bbbf70a64 100644 --- a/.claude/hooks/enforce-powershell-batch-budget.ps1 +++ b/.claude/hooks/enforce-powershell-batch-budget.ps1 @@ -14,6 +14,19 @@ persisted under .claude/state/powershell-batch-budget..json. Only distinct file paths are counted; repeated edits to the same file consume one slot. + The session id is resolved from the first non-empty of: the CLAUDE_SESSION_ID + environment variable; the contents of /.claude/state/current-session-id; + a worktree-derived identifier built from the root's leaf name and a short + stable hash of its normalized path. The resolved value is sanitized before it + is composed into a file name, so a hostile id cannot escape the state + directory. Resolving the id never creates the state directory. + + Candidate paths are contained to the resolved root. A candidate that resolves + outside it is discarded: the decision is 'allow', no slot is consumed, and no + state is written. Persisted entries that fail the same containment test are + dropped when state is rehydrated, so a state file carried between worktrees + cannot spend this worktree's budget. + Test files are those matching: - tests/**/*.ps1 - *.Tests.ps1 @@ -39,6 +52,127 @@ param() Import-Module (Join-Path $PSScriptRoot '../lib/hook-payload/HookPayload.psm1') -Force + +function Test-PowerShellBatchBudgetPathInRoot { + <# + .SYNOPSIS + Reports whether a candidate path belongs to the batch-budget root. + .DESCRIPTION + Compares forward-slash-normalized forms of the candidate and the root, + case-insensitively. A relative candidate carries no root of its own and is + admitted, which is what keeps a relative path recorded by one worktree from + being treated as foreign by another. + #> + [CmdletBinding()] + [OutputType([bool])] + param( + [AllowNull()] + [AllowEmptyString()] + [string] $Path, + + [AllowNull()] + [AllowEmptyString()] + [string] $Root + ) + + if ([string]::IsNullOrWhiteSpace($Path)) { + return $false + } + + $normalizedPath = $Path -replace '\\', '/' + if ($normalizedPath -notmatch '^(/|[A-Za-z]:/)') { + return $true + } + + $normalizedRoot = ($Root -replace '\\', '/').TrimEnd('/') + if ([string]::IsNullOrWhiteSpace($normalizedRoot)) { + return $true + } + + return ([string]::Equals($normalizedPath, $normalizedRoot, [System.StringComparison]::OrdinalIgnoreCase) -or $normalizedPath.StartsWith($normalizedRoot + '/', [System.StringComparison]::OrdinalIgnoreCase)) +} + +function ConvertTo-PowerShellBatchBudgetSafeSegment { + <# + .SYNOPSIS + Reduces a session id to characters that are safe in a file name. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [AllowNull()] + [AllowEmptyString()] + [string] $Value + ) + + return ($Value -replace '[^A-Za-z0-9._-]', '_') +} + +function Get-PowerShellBatchBudgetSessionId { + <# + .SYNOPSIS + Resolves the session id used to compose the batch-budget state-file name. + .DESCRIPTION + Returns the first non-empty of: the explicit SessionId argument; the + CLAUDE_SESSION_ID environment variable; the contents of the session-id state + file; a worktree-derived identifier. The result is sanitized so it cannot + escape the state directory. The session-id state file is read through the + ReadSessionIdFile seam and is never created, so resolution performs no write. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [AllowNull()] + [AllowEmptyString()] + [string] $SessionId, + + [AllowNull()] + [AllowEmptyString()] + [string] $Root, + + [Parameter(Mandatory)] + [string] $SessionIdFilePath, + + [scriptblock] $ReadSessionIdFile + ) + + $candidates = @( + $SessionId + $env:CLAUDE_SESSION_ID + ) + + foreach ($candidate in $candidates) { + if (-not [string]::IsNullOrWhiteSpace($candidate)) { + return (ConvertTo-PowerShellBatchBudgetSafeSegment -Value $candidate.Trim()) + } + } + + $fromFile = '' + try { + $fromFile = [string](& $ReadSessionIdFile $SessionIdFilePath) + } catch { + Write-Verbose "Ignoring unreadable session-id file '$SessionIdFilePath': $($_.Exception.Message)" + $fromFile = '' + } + + if (-not [string]::IsNullOrWhiteSpace($fromFile)) { + return (ConvertTo-PowerShellBatchBudgetSafeSegment -Value $fromFile.Trim()) + } + + $normalizedRoot = ($Root -replace '\\', '/').TrimEnd('/') + $leaf = ConvertTo-PowerShellBatchBudgetSafeSegment -Value (Split-Path -Path $normalizedRoot -Leaf) + + $sha = [System.Security.Cryptography.SHA256]::Create() + try { + $hashBytes = $sha.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($normalizedRoot)) + } finally { + $sha.Dispose() + } + $shortHash = -join (@($hashBytes[0..3]) | ForEach-Object { $_.ToString('x2') }) + + return "worktree-$leaf-$shortHash" +} + function Get-PowerShellBatchBudgetState { [CmdletBinding()] [OutputType([System.Collections.Specialized.OrderedDictionary])] @@ -69,14 +203,26 @@ function ConvertTo-PowerShellBatchBudgetState { [int] $ProdCap, [Parameter(Mandatory)] - [int] $TestCap + [int] $TestCap, + + [AllowNull()] + [AllowEmptyString()] + [string] $Root = (Split-Path (Split-Path $PSScriptRoot -Parent) -Parent) ) $state = Get-PowerShellBatchBudgetState -ProdCap $ProdCap -TestCap $TestCap if ($null -ne $InputObject.prodCap) { $state.prodCap = [int]$InputObject.prodCap } if ($null -ne $InputObject.testCap) { $state.testCap = [int]$InputObject.testCap } - if ($null -ne $InputObject.prodFiles) { $state.prodFiles = @($InputObject.prodFiles) } - if ($null -ne $InputObject.testFiles) { $state.testFiles = @($InputObject.testFiles) } + + # Persisted entries that resolve outside this root belong to another worktree + # and are dropped, so a state file carried across worktrees cannot spend this + # worktree's budget. + if ($null -ne $InputObject.prodFiles) { + $state.prodFiles = @(@($InputObject.prodFiles) | Where-Object { Test-PowerShellBatchBudgetPathInRoot -Path $_ -Root $Root }) + } + if ($null -ne $InputObject.testFiles) { + $state.testFiles = @(@($InputObject.testFiles) | Where-Object { Test-PowerShellBatchBudgetPathInRoot -Path $_ -Root $Root }) + } return $state } @@ -116,7 +262,11 @@ function Invoke-PowerShellBatchBudgetDecision { [System.Collections.IDictionary] $State, [Parameter(Mandatory)] - [string] $StateFile + [string] $StateFile, + + [AllowNull()] + [AllowEmptyString()] + [string] $Root = (Split-Path (Split-Path $PSScriptRoot -Parent) -Parent) ) $normalized = $FilePath -replace '\\', '/' @@ -124,6 +274,13 @@ function Invoke-PowerShellBatchBudgetDecision { return [ordered]@{ hookSpecificOutput = [ordered]@{ hookEventName = 'PreToolUse'; permissionDecision = 'allow' }; state = $State; shouldWriteState = $false } } + # An out-of-root candidate is discarded rather than denied: it consumes no + # slot and writes no state, so this hook stays deny-only for real overruns. + if (-not (Test-PowerShellBatchBudgetPathInRoot -Path $normalized -Root $Root)) { + Write-Verbose "Discarding batch-budget candidate '$normalized': it resolves outside the batch-budget root '$Root'." + return [ordered]@{ hookSpecificOutput = [ordered]@{ hookEventName = 'PreToolUse'; permissionDecision = 'allow' }; state = $State; shouldWriteState = $false } + } + $isTestFile = ($normalized -match '(^|/)tests/.*\.ps1$') -or ($normalized -match '\.Tests\.ps1$') $targetList = if ($isTestFile) { @($State.testFiles) } else { @($State.prodFiles) } $cap = if ($isTestFile) { [int]$State.testCap } else { [int]$State.prodCap } @@ -154,10 +311,17 @@ function Invoke-PowerShellBatchBudgetHook { [OutputType([System.Collections.Specialized.OrderedDictionary])] param( [string] $ToolInputRaw, - [string] $SessionId = 'default', - [string] $Root = (Get-Location).Path, + [string] $SessionId = '', + [string] $Root = (Split-Path (Split-Path $PSScriptRoot -Parent) -Parent), [int] $ProdCap = 3, [int] $TestCap = 3, + [scriptblock] $ReadSessionIdFile = { + param([string] $Path) + if (Test-Path -LiteralPath $Path -PathType Leaf) { + return (Get-Content -LiteralPath $Path -Raw) + } + return '' + }, [scriptblock] $TestPathExists = { param([string] $Path) Test-Path -Path $Path }, [scriptblock] $EnsureDirectory = { param([string] $Path) New-Item -ItemType Directory -Path $Path -Force | Out-Null }, [scriptblock] $ReadState = { param([string] $Path) Get-Content -Path $Path -Raw }, @@ -186,23 +350,32 @@ function Invoke-PowerShellBatchBudgetHook { } $stateDir = Join-Path -Path $Root -ChildPath '.claude/state' + + # Resolved before the directory is ensured, because reading the session-id + # file must never be what creates the state directory. + $resolvedSessionId = Get-PowerShellBatchBudgetSessionId ` + -SessionId $SessionId ` + -Root $Root ` + -SessionIdFilePath (Join-Path -Path $stateDir -ChildPath 'current-session-id') ` + -ReadSessionIdFile $ReadSessionIdFile + if (-not (& $TestPathExists $stateDir)) { & $EnsureDirectory $stateDir } - $stateFile = Join-Path -Path $stateDir -ChildPath ("powershell-batch-budget.$SessionId.json") + $stateFile = Join-Path -Path $stateDir -ChildPath ("powershell-batch-budget.$resolvedSessionId.json") $state = Get-PowerShellBatchBudgetState -ProdCap $ProdCap -TestCap $TestCap if (& $TestPathExists $stateFile) { try { $loaded = & $ReadState $stateFile | ConvertFrom-Json -ErrorAction Stop - $state = ConvertTo-PowerShellBatchBudgetState -InputObject $loaded -ProdCap $ProdCap -TestCap $TestCap + $state = ConvertTo-PowerShellBatchBudgetState -InputObject $loaded -ProdCap $ProdCap -TestCap $TestCap -Root $Root } catch { Write-Verbose "Ignoring unreadable PowerShell batch-budget state file '$stateFile': $($_.Exception.Message)" } } - $decision = Invoke-PowerShellBatchBudgetDecision -FilePath $filePath -State $state -StateFile $stateFile + $decision = Invoke-PowerShellBatchBudgetDecision -FilePath $filePath -State $state -StateFile $stateFile -Root $Root if ($decision.shouldWriteState) { try { & $WriteState $stateFile $decision.state @@ -245,10 +418,10 @@ function Invoke-PowerShellBatchBudgetEntryPoint { $ToolInputRaw = [string](& $ReadPayload) } - $sessionId = $env:CLAUDE_SESSION_ID - if (-not $sessionId) { - $sessionId = 'default' - } + # No literal fallback here: an empty value routes through the hook's session + # resolution, which falls back to the session-id state file and then to a + # worktree-derived identifier. + $sessionId = [string]$env:CLAUDE_SESSION_ID $prodCap = 3 $testCap = 3 diff --git a/.claude/hooks/persist-session-id.ps1 b/.claude/hooks/persist-session-id.ps1 index 933b668e4..8c0d0b1d7 100644 --- a/.claude/hooks/persist-session-id.ps1 +++ b/.claude/hooks/persist-session-id.ps1 @@ -8,13 +8,15 @@ falling back to the CLAUDE_HOOK_INPUT environment variable (the existing SubagentStop-hook precedent), and extracts the 'session_id' field. - Persistence channel: - - When CLAUDE_ENV_FILE is set, appends the line - 'CLAUDE_SESSION_ID=' to that file. Variables persisted there are + Persistence channels: + - The id is always written to .claude/state/current-session-id, creating + that directory when absent. Publishing it unconditionally is what lets a + consumer that cannot read the env file, such as a hook running in a + fresh process, still resolve the session id from disk. + - When CLAUDE_ENV_FILE is set, the line 'CLAUDE_SESSION_ID=' is + additionally appended to that file. Variables persisted there are exported to subsequent Bash tool commands in the session, which is how this hook provisions the otherwise-unset CLAUDE_SESSION_ID variable. - - When CLAUDE_ENV_FILE is unset, writes the id to - .claude/state/current-session-id instead. On malformed or empty input (missing/blank payload, unparseable JSON, or an absent/blank session_id) the hook performs no write. It always exits 0 so a @@ -101,6 +103,15 @@ function Invoke-PersistSessionIdHook { switch ($decision.action) { 'env-file' { & $AppendLine $decision.path ("CLAUDE_SESSION_ID={0}" -f $decision.sessionId) + + # The state file is published unconditionally, so a consumer that + # cannot read the env file (a hook invoked in a fresh process, for + # example) still resolves the session id from disk. + $envStateDir = Split-Path -Path $StateFilePath -Parent + if ($envStateDir) { + & $EnsureDirectory $envStateDir + } + & $WriteStateFile $StateFilePath $decision.sessionId } 'state-file' { $stateDir = Split-Path -Path $decision.path -Parent diff --git a/.claude/hooks/validate-planner-output.ps1 b/.claude/hooks/validate-planner-output.ps1 index 62c18ea14..65ae0c831 100644 --- a/.claude/hooks/validate-planner-output.ps1 +++ b/.claude/hooks/validate-planner-output.ps1 @@ -110,6 +110,123 @@ function Test-HasPreflightSignal { ) } +function Get-PlannerInternalReviewValidation { + [CmdletBinding()] + [OutputType([hashtable])] + param([Parameter(Mandatory = $true)][string] $AgentOutput) + + $lines = @($AgentOutput -split "`r?`n") + $labelPattern = '^\s*(?:PLANNER-INTERNAL-REVIEW|CITATION-TO-TREE|AC-TRACEABILITY|SCOPE-BOUNDARY|CITATION|AC-INVENTORY|AC-MAPPING|UNRESOLVED-GAPS|PREFLIGHT)\s*:' + $headerPattern = '^\s*PLANNER-INTERNAL-REVIEW\s*:\s*(?.*?)\s*$' + $preflightPattern = '^\s*PREFLIGHT\s*:\s*(?:ALL CLEAR|REVISIONS REQUIRED)\s*$' + $labelIndexes = [System.Collections.Generic.List[int]]::new() + $headerIndexes = [System.Collections.Generic.List[int]]::new() + $preflightIndexes = [System.Collections.Generic.List[int]]::new() + + for ($index = 0; $index -lt $lines.Count; $index++) { + if ($lines[$index] -match $labelPattern) { + $labelIndexes.Add($index) + } + if ($lines[$index] -match $headerPattern) { + $headerIndexes.Add($index) + } + if ($lines[$index] -match $preflightPattern) { + $preflightIndexes.Add($index) + } + } + + if ($headerIndexes.Count -ne 1) { + return @{ Ok = $false; Message = 'planner internal review must contain exactly one `PLANNER-INTERNAL-REVIEW:` declaration.' } + } + + $headerIndex = $headerIndexes[0] + $headerMatch = [regex]::Match($lines[$headerIndex], $headerPattern) + if ($headerMatch.Groups['Value'].Value.Trim() -ne 'PASS') { + return @{ Ok = $false; Message = 'planner internal review header must be exactly `PLANNER-INTERNAL-REVIEW: PASS`.' } + } + + $preflightAfterHeader = @($preflightIndexes | Where-Object { $_ -gt $headerIndex }) + if ($preflightAfterHeader.Count -ne 1) { + return @{ Ok = $false; Message = 'planner internal review must terminate at exactly one required `PREFLIGHT:` signal after its header.' } + } + + $preflightIndex = $preflightAfterHeader[0] + foreach ($labelIndex in $labelIndexes) { + if ($labelIndex -lt $headerIndex -or $labelIndex -gt $preflightIndex) { + return @{ Ok = $false; Message = 'planner internal review declarations must not occur outside the bounded record.' } + } + } + + $recordLines = @($lines[$headerIndex..$preflightIndex]) + $requiredDeclarations = @('CITATION-TO-TREE', 'AC-TRACEABILITY', 'SCOPE-BOUNDARY') + foreach ($declaration in $requiredDeclarations) { + $declarationMatches = @($recordLines | Where-Object { $_ -match "^\s*$declaration\s*:" }) + if ($declarationMatches.Count -ne 1) { + return @{ Ok = $false; Message = "planner internal review must contain exactly one `$declaration: PASS` declaration." } + } + if ($declarationMatches[0] -notmatch "^\s*$declaration\s*:\s*PASS\s*$") { + return @{ Ok = $false; Message = "planner internal review declaration `$declaration must be exactly PASS." } + } + } + + $citationLines = @($recordLines | Where-Object { $_ -match '^\s*CITATION\s*:' }) + if ($citationLines.Count -eq 0) { + return @{ Ok = $false; Message = 'planner internal review must contain at least one `CITATION:` record.' } + } + foreach ($citationLine in $citationLines) { + $citation = [regex]::Match($citationLine, '^\s*CITATION\s*:\s*(?[^|\s]+)\s*\|\s*(?.+?\S)\s*$') + if (-not $citation.Success -or $citation.Groups['Path'].Value -notmatch '^(?![A-Za-z]:)(?!/)(?!\\)(?:\.?[^/\\|\s]+)(?:/[^/\\|\s]+)+$') { + return @{ Ok = $false; Message = 'planner internal review citations require a repository-relative path and nonblank locator.' } + } + } + + $inventoryLines = @($recordLines | Where-Object { $_ -match '^\s*AC-INVENTORY\s*:' }) + if ($inventoryLines.Count -ne 1) { + return @{ Ok = $false; Message = 'planner internal review must contain exactly one nonblank `AC-INVENTORY:` declaration.' } + } + $inventoryValue = ($inventoryLines[0] -replace '^\s*AC-INVENTORY\s*:\s*', '').Trim() + if ([string]::IsNullOrWhiteSpace($inventoryValue)) { + return @{ Ok = $false; Message = 'planner internal review `AC-INVENTORY:` must contain nonblank unique IDs.' } + } + $inventoryIds = @($inventoryValue -split ',' | ForEach-Object { $_.Trim() }) + if ((@($inventoryIds | Where-Object { [string]::IsNullOrWhiteSpace($_) }).Count -gt 0) -or + (@($inventoryIds | Select-Object -Unique).Count -ne $inventoryIds.Count)) { + return @{ Ok = $false; Message = 'planner internal review `AC-INVENTORY:` must contain nonblank unique IDs.' } + } + + $mappingLines = @($recordLines | Where-Object { $_ -match '^\s*AC-MAPPING\s*:' }) + if ($mappingLines.Count -eq 0) { + return @{ Ok = $false; Message = 'planner internal review must contain one `AC-MAPPING:` record for every inventory ID.' } + } + $mappingIds = [System.Collections.Generic.List[string]]::new() + foreach ($mappingLine in $mappingLines) { + $mapping = [regex]::Match($mappingLine, '^\s*AC-MAPPING\s*:\s*(?[^|]*?)\s*\|\s*IMPLEMENTATION\s*:\s*(?[^|]*?)\s*\|\s*TESTS\s*:\s*(?[^|]*?)\s*\|\s*EVIDENCE\s*:\s*(?.*?)\s*$') + if (-not $mapping.Success -or + [string]::IsNullOrWhiteSpace($mapping.Groups['Id'].Value) -or + [string]::IsNullOrWhiteSpace($mapping.Groups['Implementation'].Value) -or + [string]::IsNullOrWhiteSpace($mapping.Groups['Tests'].Value) -or + [string]::IsNullOrWhiteSpace($mapping.Groups['Evidence'].Value)) { + return @{ Ok = $false; Message = 'planner internal review `AC-MAPPING:` requires nonblank ID, IMPLEMENTATION, TESTS, and EVIDENCE fields.' } + } + $mappingIds.Add($mapping.Groups['Id'].Value.Trim()) + } + if (@($mappingIds | Select-Object -Unique).Count -ne $mappingIds.Count) { + return @{ Ok = $false; Message = 'planner internal review `AC-MAPPING:` identifiers must be unique.' } + } + if ($inventoryIds.Count -ne $mappingIds.Count -or + (@($inventoryIds | Where-Object { $_ -notin $mappingIds }).Count -gt 0) -or + (@($mappingIds | Where-Object { $_ -notin $inventoryIds }).Count -gt 0)) { + return @{ Ok = $false; Message = 'planner internal review AC inventory and mapping identifiers must match exactly.' } + } + + $gapLines = @($recordLines | Where-Object { $_ -match '^\s*UNRESOLVED-GAPS\s*:' }) + if ($gapLines.Count -ne 1 -or $gapLines[0] -notmatch '^\s*UNRESOLVED-GAPS\s*:\s*NONE\s*$') { + return @{ Ok = $false; Message = 'planner internal review must contain exactly one `UNRESOLVED-GAPS: NONE` declaration.' } + } + + return @{ Ok = $true; Message = $null } +} + function Get-PlanStructureValidationReport { [CmdletBinding()] [OutputType([string[]])] @@ -272,6 +389,11 @@ function Invoke-PlannerOutputValidation { return @{ Ok = $false; Message = $message } } + $review = Get-PlannerInternalReviewValidation -AgentOutput $agentOutput + if (-not $review.Ok) { + return @{ Ok = $false; Message = "atomic-planner hook: $($review.Message)" } + } + return @{ Ok = $true; Message = $null } } diff --git a/.claude/hooks/validate-prd-feature-output.ps1 b/.claude/hooks/validate-prd-feature-output.ps1 new file mode 100644 index 000000000..5bc87c1d5 --- /dev/null +++ b/.claude/hooks/validate-prd-feature-output.ps1 @@ -0,0 +1,91 @@ +<# +.SYNOPSIS + Validates numeric acceptance criteria produced by the prd-feature worker. +#> + +[CmdletBinding()] +param() + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Get-ArtifactPathFromOutput { + [CmdletBinding()] + [OutputType([string])] + param([Parameter(Mandatory = $true)][string] $Output, [Parameter(Mandatory = $true)][string] $Label) + + $match = [regex]::Match($Output, "(?im)^\s*$([regex]::Escape($Label))\s*:\s*(?\S+)") + if ($match.Success) { return $match.Groups['path'].Value } + return $null +} + +function Test-NumericDerivationEvidence { + [CmdletBinding()] + [OutputType([hashtable])] + param([Parameter(Mandatory = $true)][AllowEmptyString()][string] $Content) + + $section = [regex]::Match($Content, '(?ims)^##\s+Numeric\s+Derivation\s+Evidence\s*$.*?(?=^##\s|\z)') + if (-not $section.Success) { return @{ Ok = $false; Message = 'prd-feature hook: numeric criterion requires ## Numeric Derivation Evidence.' } } + $requiredLabels = @( + 'Complete Family', 'Exhaustive Search Scope', 'Inclusion Rules', 'Exclusion Rules', + 'Primary Search Strategy or Query Expression', 'Primary Member Set', 'Primary Count', + 'Cross-check Search Strategy or Query Expression', 'Cross-check Member Set', 'Cross-check Count', + 'Member-set Comparison' + ) + $values = @{} + foreach ($label in $requiredLabels) { + $match = [regex]::Match($section.Value, "(?im)^[\t ]*[-*]?[\t ]*$([regex]::Escape($label))[\t ]*:[\t ]*(?\S(?:.*\S)?)[\t ]*$") + if (-not $match.Success) { return @{ Ok = $false; Message = "prd-feature hook: numeric derivation evidence is missing $label." } } + $values[$label] = $match.Groups['value'].Value.Trim() + } + if ($values['Exhaustive Search Scope'] -notmatch '(?i)\b(entire|all|complete)\b.*\b(repository|repo|source tree|tree)\b') { return @{ Ok = $false; Message = 'prd-feature hook: numeric derivation evidence does not declare an exhaustive repository search scope.' } } + $primaryStrategy = $values['Primary Search Strategy or Query Expression'] + $crossCheckStrategy = $values['Cross-check Search Strategy or Query Expression'] + if ($primaryStrategy -match '(?i)\b(single|narrow|named[- ]?pattern)\b' -or $crossCheckStrategy -match '(?i)\b(single|narrow|named[- ]?pattern)\b') { return @{ Ok = $false; Message = 'prd-feature hook: numeric derivation evidence uses a narrow named-pattern search.' } } + if ([regex]::Replace($primaryStrategy, '\s+', '').ToLowerInvariant() -eq [regex]::Replace($crossCheckStrategy, '\s+', '').ToLowerInvariant()) { return @{ Ok = $false; Message = 'prd-feature hook: numeric derivation cross-check repeats the primary search strategy or query expression.' } } + $familyMembers = @($values['Complete Family'].Split(',') | ForEach-Object { $_.Trim() } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + foreach ($familyMember in $familyMembers) { + if ($primaryStrategy -notmatch [regex]::Escape($familyMember) -or $crossCheckStrategy -notmatch [regex]::Escape($familyMember)) { return @{ Ok = $false; Message = "prd-feature hook: numeric derivation search does not cover complete family member '$familyMember'." } } + } + if ($values['Primary Count'] -notmatch '^\d+$' -or $values['Cross-check Count'] -notmatch '^\d+$') { return @{ Ok = $false; Message = 'prd-feature hook: numeric derivation counts must be numeric.' } } + $primaryMembers = @($values['Primary Member Set'].Split(',') | ForEach-Object { $_.Trim() } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + $crossCheckMembers = @($values['Cross-check Member Set'].Split(',') | ForEach-Object { $_.Trim() } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + if ([int]$values['Primary Count'] -ne $primaryMembers.Count -or [int]$values['Cross-check Count'] -ne $crossCheckMembers.Count) { return @{ Ok = $false; Message = 'prd-feature hook: numeric derivation count does not match its independently enumerated member set.' } } + $normalizedPrimaryMembers = @($primaryMembers | ForEach-Object { $_.ToLowerInvariant() } | Sort-Object -Unique) -join '|' + $normalizedCrossCheckMembers = @($crossCheckMembers | ForEach-Object { $_.ToLowerInvariant() } | Sort-Object -Unique) -join '|' + if ($normalizedPrimaryMembers -ne $normalizedCrossCheckMembers) { return @{ Ok = $false; Message = 'prd-feature hook: numeric derivation primary and cross-check member sets disagree.' } } + if ($values['Member-set Comparison'] -notmatch '(?i)\b(equal|match|identical)\b') { return @{ Ok = $false; Message = 'prd-feature hook: numeric derivation evidence is missing an explicit member-set comparison.' } } + return @{ Ok = $true; Message = $null } +} + +function Test-SpecNumericCriterion { + [CmdletBinding()] + [OutputType([bool])] + param([Parameter(Mandatory = $true)][AllowEmptyString()][string] $SpecContent) + + $section = [regex]::Match($SpecContent, '(?ims)^##\s+Acceptance\s+Criteria\s*$.*?(?=^##\s|\z)') + return $section.Success -and [regex]::IsMatch($section.Value, '(?m)^\s*[-*]\s*\[.\].*\b\d+\b') +} + +function Invoke-PrdFeatureOutputValidation { + [CmdletBinding()] + [OutputType([hashtable])] + param([string] $RawPayload) + + if ([string]::IsNullOrWhiteSpace($RawPayload)) { return @{ Ok = $false; Message = 'prd-feature hook: CLAUDE_HOOK_INPUT is empty.' } } + try { $payload = $RawPayload | ConvertFrom-Json -ErrorAction Stop } catch { return @{ Ok = $false; Message = "prd-feature hook: failed to parse CLAUDE_HOOK_INPUT as JSON: $($_.Exception.Message)" } } + $output = if ($null -ne $payload.PSObject.Properties['output']) { $payload.output } else { $null } + if ([string]::IsNullOrWhiteSpace($output)) { return @{ Ok = $false; Message = 'prd-feature hook: agent output is empty.' } } + $specPath = Get-ArtifactPathFromOutput -Output $output -Label 'spec-path' + if ([string]::IsNullOrWhiteSpace($specPath) -or -not (Test-Path -LiteralPath $specPath -PathType Leaf)) { return @{ Ok = $false; Message = 'prd-feature hook: spec-path is required and must exist.' } } + $specContent = Get-Content -LiteralPath $specPath -Raw -ErrorAction Stop + if (-not (Test-SpecNumericCriterion -SpecContent $specContent)) { return @{ Ok = $true; Message = $null } } + $researchPath = Get-ArtifactPathFromOutput -Output $output -Label 'research-path' + if ([string]::IsNullOrWhiteSpace($researchPath) -or -not (Test-Path -LiteralPath $researchPath -PathType Leaf)) { return @{ Ok = $false; Message = 'prd-feature hook: numeric acceptance criterion requires an existing research-path.' } } + return Test-NumericDerivationEvidence -Content (Get-Content -LiteralPath $researchPath -Raw -ErrorAction Stop) +} + +if ($MyInvocation.InvocationName -eq '.') { return } +$result = Invoke-PrdFeatureOutputValidation -RawPayload $env:CLAUDE_HOOK_INPUT +if (-not $result.Ok) { Write-Error $result.Message; exit 1 } +exit 0 diff --git a/.claude/hooks/validate-task-researcher-output.ps1 b/.claude/hooks/validate-task-researcher-output.ps1 index d6c7c8dfd..7ab10fb29 100644 --- a/.claude/hooks/validate-task-researcher-output.ps1 +++ b/.claude/hooks/validate-task-researcher-output.ps1 @@ -161,6 +161,68 @@ function Test-AutomationFeasibilitySection { return @{ Ok = $true; Message = $null } } +function Test-NumericDerivationEvidence { + [CmdletBinding()] + [OutputType([hashtable])] + param( + [Parameter(Mandatory = $true)] + [AllowEmptyString()] + [string] $Content + ) + + $numericClaimPattern = '(?im)^\s*[-*]\s*Numeric\s+spec\.md\s+acceptance\s+criterion:\s*.*\b\d+\b' + if (-not [regex]::IsMatch($Content, $numericClaimPattern)) { + return @{ Ok = $true; Message = $null } + } + + $section = [regex]::Match($Content, '(?ims)^##\s+Numeric\s+Derivation\s+Evidence\s*$.*?(?=^##\s|\z)') + if (-not $section.Success) { + return @{ Ok = $false; Message = 'task-researcher hook: numeric spec.md acceptance criterion is missing ## Numeric Derivation Evidence.' } + } + + $requiredLabels = @( + 'Complete Family', 'Exhaustive Search Scope', 'Inclusion Rules', 'Exclusion Rules', + 'Primary Search Strategy or Query Expression', 'Primary Member Set', 'Primary Count', + 'Cross-check Search Strategy or Query Expression', 'Cross-check Member Set', 'Cross-check Count', + 'Member-set Comparison' + ) + $values = @{} + foreach ($label in $requiredLabels) { + $match = [regex]::Match($section.Value, "(?im)^[\t ]*[-*]?[\t ]*$([regex]::Escape($label))[\t ]*:[\t ]*(?\S(?:.*\S)?)[\t ]*$") + if (-not $match.Success) { return @{ Ok = $false; Message = "task-researcher hook: numeric derivation evidence is missing $label." } } + $values[$label] = $match.Groups['value'].Value.Trim() + } + if ($values['Exhaustive Search Scope'] -notmatch '(?i)\b(entire|all|complete)\b.*\b(repository|repo|source tree|tree)\b') { + return @{ Ok = $false; Message = 'task-researcher hook: numeric derivation evidence does not declare an exhaustive repository search scope.' } + } + $primaryStrategy = $values['Primary Search Strategy or Query Expression'] + $crossCheckStrategy = $values['Cross-check Search Strategy or Query Expression'] + if ($primaryStrategy -match '(?i)\b(single|narrow|named[- ]?pattern)\b' -or $crossCheckStrategy -match '(?i)\b(single|narrow|named[- ]?pattern)\b') { + return @{ Ok = $false; Message = 'task-researcher hook: numeric derivation evidence uses a narrow named-pattern search.' } + } + if ([regex]::Replace($primaryStrategy, '\s+', '').ToLowerInvariant() -eq [regex]::Replace($crossCheckStrategy, '\s+', '').ToLowerInvariant()) { + return @{ Ok = $false; Message = 'task-researcher hook: numeric derivation cross-check repeats the primary search strategy or query expression.' } + } + $familyMembers = @($values['Complete Family'].Split(',') | ForEach-Object { $_.Trim() } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + foreach ($familyMember in $familyMembers) { + if ($primaryStrategy -notmatch [regex]::Escape($familyMember) -or $crossCheckStrategy -notmatch [regex]::Escape($familyMember)) { + return @{ Ok = $false; Message = "task-researcher hook: numeric derivation search does not cover complete family member '$familyMember'." } + } + } + if ($values['Primary Count'] -notmatch '^\d+$' -or $values['Cross-check Count'] -notmatch '^\d+$') { return @{ Ok = $false; Message = 'task-researcher hook: numeric derivation counts must be numeric.' } } + $primaryMembers = @($values['Primary Member Set'].Split(',') | ForEach-Object { $_.Trim() } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + $crossCheckMembers = @($values['Cross-check Member Set'].Split(',') | ForEach-Object { $_.Trim() } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + if ([int]$values['Primary Count'] -ne $primaryMembers.Count -or [int]$values['Cross-check Count'] -ne $crossCheckMembers.Count) { + return @{ Ok = $false; Message = 'task-researcher hook: numeric derivation count does not match its independently enumerated member set.' } + } + $normalizedPrimaryMembers = @($primaryMembers | ForEach-Object { $_.ToLowerInvariant() } | Sort-Object -Unique) -join '|' + $normalizedCrossCheckMembers = @($crossCheckMembers | ForEach-Object { $_.ToLowerInvariant() } | Sort-Object -Unique) -join '|' + if ($normalizedPrimaryMembers -ne $normalizedCrossCheckMembers) { return @{ Ok = $false; Message = 'task-researcher hook: numeric derivation primary and cross-check member sets disagree.' } } + if ($values['Member-set Comparison'] -notmatch '(?i)\b(equal|match|identical)\b') { return @{ Ok = $false; Message = 'task-researcher hook: numeric derivation evidence is missing an explicit member-set comparison.' } } + + return @{ Ok = $true; Message = $null } +} + function Invoke-TaskResearcherOutputValidation { [CmdletBinding()] [OutputType([hashtable])] @@ -209,6 +271,13 @@ function Invoke-TaskResearcherOutputValidation { return @{ Ok = $false; Message = $feasibilityResult.Message } } + if (Test-Path -LiteralPath $researchPath -PathType Leaf) { + $numericEvidenceResult = Test-NumericDerivationEvidence -Content (Get-Content -LiteralPath $researchPath -Raw -ErrorAction Stop) + if (-not $numericEvidenceResult.Ok) { + return $numericEvidenceResult + } + } + return @{ Ok = $true; Message = $null } } @@ -223,4 +292,3 @@ if (-not $result.Ok) { } exit 0 - diff --git a/.claude/lib/bash/parallel-lane-assertion.sh b/.claude/lib/bash/parallel-lane-assertion.sh new file mode 100644 index 000000000..0bb7aae23 --- /dev/null +++ b/.claude/lib/bash/parallel-lane-assertion.sh @@ -0,0 +1,495 @@ +#!/usr/bin/env bash +# parallel-lane-assertion.sh: sourceable bash port of +# scripts/dev_tools/parallel_lane_assertion.py. Answers one question for a +# parallel-run planner: did the hand-authored lane grouping in the manifest's +# optional `expected_conflict_components` key (invariant M8) survive +# blast-radius derivation? The module derives the connected components of the +# DERIVED conflict graph and reports every disagreement with the asserted +# grouping, in four classes. The Python module remains the repository authority +# and this file must reproduce its output byte for byte. +# +# Responsibilities and boundaries. This module is a DIAGNOSTIC. It never +# overrides a derived conflict edge, never feeds the cohort computation, never +# influences scheduling, and writes no artifact and no checkpoint field. Every +# finding is advisory: the entry point reports and exits 0 whether or not +# findings were produced, so a disagreement can never block a planning run. The +# asserted grouping is an ASSERTION, not a declaration. +# +# Scope. Every function here is pure in the sense the Python reference declares +# for itself at scripts/dev_tools/parallel_lane_assertion.py:34-38: it reads the +# node table pm_parse_manifest already populated plus its own arguments, writes +# only this module's own PLA_ globals, reads no file and no clock, and starts no +# process other than `sort`. The single I/O boundary is the entry point +# .claude/lib/bash/report-lane-assertion.sh, which calls pc_enforce_c_locale +# before any work, so every sort and character class below is byte ordered. +# +# shellcheck disable=SC2034 +# SC2034 is disabled file-wide because the class-token constants and the result +# globals declared here are written in this file and read by the entry point +# .claude/lib/bash/report-lane-assertion.sh, which shellcheck analyses as a +# separate file and therefore cannot see the use. + +# Resolve this file's own directory so its dependency sources regardless of the +# caller's working directory. +PLA_LIB_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=.claude/lib/bash/parallel-manifest-validate.sh +# shellcheck disable=SC1091 +source "$PLA_LIB_DIR/parallel-manifest-validate.sh" + +# The four report classes. Each is a stable token so a consumer can group +# findings without parsing the human-readable detail text. +PLA_EXPECTED_TOGETHER_DERIVED_APART="expected_together_derived_apart" +PLA_EXPECTED_APART_DERIVED_TOGETHER="expected_apart_derived_together" +PLA_MEMBER_NAMES_NO_ITEM="member_names_no_item" +PLA_ITEM_COVERED_BY_NO_COMPONENT="item_covered_by_no_component" + +# The single informational class, held as a space-separated set so +# pc_contains_word decides membership. The other three classes indicate a real +# disagreement with the derived graph; this one only reports that the assertion +# was silent about an item. +PLA_INFORMATIONAL_KINDS="$PLA_ITEM_COVERED_BY_NO_COMPONENT" + +# Endpoint separator on the command line, matching the `--edges ": ..."` +# convention of the cohort-computation entry points. +PLA_EDGE_SEPARATOR=":" + +# Parsed conflict edges in input order, one ` ` pair per element. +# Populated by pla_parse_edges and read by pla_derive_components. +PLA_EDGES=() + +pla_is_lexical_integer() { + # Return 0 when a token is a decimal integer in the strict accepted lexis. + # + # Args: $1 = the raw token. The lexis is the one + # .claude/lib/bash/compute-cohorts.sh:59 already applies, so the entry points + # agree on what an endpoint is. It is deliberately stricter than Python's + # int(), which also accepts a leading `+`, a leading zero, an underscore + # separator, and a non-ASCII decimal digit; those forms are a declared + # divergence class, excluded rather than reproduced, because reproducing them + # would need a second lexis no other entry point shares. + [[ $1 =~ ^-?(0|[1-9][0-9]*)$ ]] +} + +pla_parse_edges() { + # Populate PLA_EDGES from a whitespace-separated `:` edge list. + # + # Args: $1 = the raw --edges value; empty or whitespace-only yields no + # edges. A token that is not two accepted integers separated by a colon is + # dropped rather than aborting the diagnostic, which would deny the operator + # the findings that the remaining edges still support. Input order is + # preserved because it is observable through the derived-component order. + local text="${1-}" token first second + PLA_EDGES=() + local -a tokens=() + # read -ra rather than an unquoted expansion, so a token carrying a glob + # character is never subjected to pathname expansion. + read -ra tokens <<<"$text" + for token in "${tokens[@]}"; do + # Partition on the FIRST colon, matching str.partition: a token with two + # colons yields a second endpoint that still carries one, which the lexis + # then rejects. + [[ $token == *"$PLA_EDGE_SEPARATOR"* ]] || continue + first="${token%%"$PLA_EDGE_SEPARATOR"*}" + second="${token#*"$PLA_EDGE_SEPARATOR"}" + pla_is_lexical_integer "$first" || continue + pla_is_lexical_integer "$second" || continue + PLA_EDGES+=("$first $second") + done +} + +pla_sort_numeric() { + # Echo a space-separated integer list sorted ascending. + # + # Args: $1 = the space-separated list, $2 = `unique` to collapse repeats. + # Duplicates are retained by default because a finding's member list + # reproduces the asserted membership, repeats included, whereas the declared + # item keys are a set. `sort -n` is used rather than a shell comparison so + # ordering is numeric under the C locale pc_enforce_c_locale pins. + local input="${1-}" mode="${2-}" out="" line + local -a words=() + read -ra words <<<"$input" + ((${#words[@]} > 0)) || return 0 + # Negated test so the guard's own status is 0 on both branches under set -e. + local -a flags=(-n) + [[ $mode != unique ]] || flags=(-n -u) + while IFS= read -r line; do + [[ -n $line ]] || continue + out="$out $line" + done < <(printf '%s\n' "${words[@]}" | sort "${flags[@]}") + printf '%s' "${out# }" +} + +# Asserted lanes read from the manifest, as parallel arrays indexed by the +# position of the entry within the READABLE entries, which is the index the +# Python reference's enumerate() produces and therefore the index that reaches +# the `component[{position}]` label. +PLA_EXPECTED_COUNT=0 +PLA_EXPECTED_HAS_NAME=() +PLA_EXPECTED_NAMES=() +PLA_EXPECTED_MEMBERS=() + +# Declared item keys, space separated, de-duplicated and ascending. +PLA_ITEM_KEYS="" + +pla_read_expected_components() { + # Populate the PLA_EXPECTED_* arrays from expected_conflict_components. + # + # Reads defensively: every shape rule is already enforced by invariant M8, + # so a malformed entry is skipped rather than reported -- a diagnostic must + # not fail on input a validator already rejects, and must not emit a second + # message for a defect M8 reports once. + local root="expected_conflict_components" total position comp_path + local members_path member_path member_count index members + local member_type member_value + PLA_EXPECTED_COUNT=0 + PLA_EXPECTED_HAS_NAME=() + PLA_EXPECTED_NAMES=() + PLA_EXPECTED_MEMBERS=() + [[ $(yp_type_of "$root") == seq ]] || return 0 + total=$(yp_count_of "$root") + for ((position = 0; position < total; position++)); do + comp_path="${root}[${position}]" + [[ $(yp_type_of "$comp_path") == map ]] || continue + members_path="${comp_path}.members" + [[ $(yp_type_of "$members_path") == seq ]] || continue + # A non-string name reads as absent, so the entry is labelled by + # position; the name is diagnostic only and never carries identity. + if [[ $(yp_type_of "${comp_path}.name") == str ]]; then + PLA_EXPECTED_HAS_NAME+=(1) + PLA_EXPECTED_NAMES+=("$(yp_value_of "${comp_path}.name")") + else + PLA_EXPECTED_HAS_NAME+=(0) + PLA_EXPECTED_NAMES+=("") + fi + members="" + member_count=$(yp_count_of "$members_path") + # Manifest order, no de-duplication: the reference keeps the authored + # tuple as written and sorts only when it renders a finding. + for ((index = 0; index < member_count; index++)); do + member_path="${members_path}[${index}]" + member_type=$(yp_type_of "$member_path") + member_value=$(yp_value_of "$member_path") + pc_is_positive_integer "$member_type" "$member_value" || continue + members="$members $member_value" + done + PLA_EXPECTED_MEMBERS+=("${members# }") + PLA_EXPECTED_COUNT=$((PLA_EXPECTED_COUNT + 1)) + done +} + +pla_read_manifest_inputs() { + # Read the asserted lanes and the declared item keys from the node table. + # + # The node table must already be populated by pm_parse_manifest. Populates + # PLA_EXPECTED_COUNT, the three PLA_EXPECTED_* arrays, and PLA_ITEM_KEYS. + # Either side may be empty, which is the manifest that asserts nothing and + # the manifest that declares no item. + pla_read_expected_components + # pm_declared_issue_nums already applies the positive-integer test the + # reference's guard applies, so the two agree on what resolves to an item. + PLA_ITEM_KEYS=$(pla_sort_numeric "$(pm_declared_issue_nums)" unique) +} + +# The derived partition: one element per component, each a space-separated +# ascending member list, ordered by lowest member. +PLA_COMPONENTS=() + +pla_derive_components() { + # Populate PLA_COMPONENTS with the connected components of the conflict + # graph. + # + # Args: $1 = space-separated declared item keys. Reads the parsed edge list + # from PLA_EDGES. A lane whose items mutually conflict is one connected + # component; two lanes sharing no edge are two components. + local keys="$1" root neighbour edge first second current head + local -a key_tokens=() roots=() neighbours=() queue=() + local -A adjacency=() seen=() + PLA_COMPONENTS=() + + # Seed adjacency from every declared key so an isolated vertex survives as + # its own single-member component. + read -ra key_tokens <<<"$keys" + for root in "${key_tokens[@]}"; do + adjacency["$root"]="" + done + + # Record each conflict on both endpoints; that symmetry, plus the + # membership test before the append, makes edge direction and repetition + # irrelevant. A self-loop and an edge naming an undeclared vertex are + # skipped rather than reported: malformed-edge reporting belongs to the + # checkpoint validators, and a diagnostic must degrade gracefully. + for edge in "${PLA_EDGES[@]}"; do + first="${edge%% *}" + second="${edge##* }" + [[ $first != "$second" ]] || continue + [[ -n ${adjacency["$first"]+set} ]] || continue + [[ -n ${adjacency["$second"]+set} ]] || continue + pc_contains_word "${adjacency["$first"]}" "$second" || + adjacency["$first"]="${adjacency["$first"]} $second" + pc_contains_word "${adjacency["$second"]}" "$first" || + adjacency["$second"]="${adjacency["$second"]} $first" + done + + # Seed a breadth-first walk from each unvisited vertex in ascending key + # order, making the component sequence a function of the graph alone. A + # component's first root is necessarily its smallest member -- a smaller one + # would have been reached from an earlier root and marked seen -- so the + # components come out ordered by lowest member and need no second sort. + read -ra roots <<<"$(pla_sort_numeric "$keys" unique)" + for root in "${roots[@]}"; do + [[ -z ${seen["$root"]+set} ]] || continue + seen["$root"]=1 + queue=("$root") + head=0 + # The visited set is marked at enqueue time, so a vertex reachable by + # two paths is enqueued once. + while ((head < ${#queue[@]})); do + current="${queue[head]}" + head=$((head + 1)) + read -ra neighbours <<<"${adjacency["$current"]}" + for neighbour in "${neighbours[@]}"; do + [[ -z ${seen["$neighbour"]+set} ]] || continue + seen["$neighbour"]=1 + queue+=("$neighbour") + done + done + PLA_COMPONENTS+=("$(pla_sort_numeric "${queue[*]}" unique)") + done +} + +# Findings in emission order, as parallel arrays. PLA_FINDING_MEMBERS holds the +# ascending item keys the finding concerns, so a consumer can act on a finding +# without re-parsing its detail text. +PLA_FINDING_KINDS=() +PLA_FINDING_DETAILS=() +PLA_FINDING_MEMBERS=() + +# Flat key -> component-index lookups on both sides, which turn every membership +# question in the comparison into a constant-time test. Neither is ever +# iterated, so no hash order reaches output. +declare -gA PLA_DERIVED_INDEX=() +declare -gA PLA_EXPECTED_INDEX=() + +pla_add_finding() { + # Append one finding: $1 = class token, $2 = detail, $3 = member list. + PLA_FINDING_KINDS+=("$1") + PLA_FINDING_DETAILS+=("$2") + PLA_FINDING_MEMBERS+=("$3") +} + +pla_count_distinct() { + # Echo the number of distinct words in a space-separated list. + # The accumulator is named distinctly from the associative `seen` set in + # pla_derive_components: shellcheck resolves a variable name file-wide, so + # reusing the name there makes it read this string as that array. + local input="${1-}" counted="" word count=0 + local -a words=() + read -ra words <<<"$input" + for word in "${words[@]}"; do + if ! pc_contains_word "$counted" "$word"; then + counted="$counted $word" + count=$((count + 1)) + fi + done + printf '%s' "$count" +} + +pla_component_label() { + # Echo the label for one asserted lane. + # + # Args: $1 = the lane's position. A lane carrying a string name renders as + # that name in single quotes, including when the name is the empty string, + # which is still a string; a lane with no usable name renders by position + # instead. The name is never used for identity. + local position="$1" + if ((PLA_EXPECTED_HAS_NAME[position] == 1)); then + printf "'%s'" "${PLA_EXPECTED_NAMES[position]}" + else + printf 'component[%s]' "$position" + fi +} + +pla_render_member_list() { + # Echo a space-separated integer list in the Python list form `[101, 102]`: + # square brackets, comma-and-space separator, no trailing comma. An empty + # list renders as `[]`. Args: $1 = the list. + local input="${1-}" out="" word + local -a words=() + read -ra words <<<"$input" + for word in "${words[@]}"; do + if [[ -n $out ]]; then + out="$out, $word" + else + out="$word" + fi + done + printf '[%s]' "$out" +} + +pla_find_split_lanes() { + # Append one finding per asserted lane whose members landed apart. + # + # Reads PLA_EXPECTED_* and PLA_DERIVED_INDEX. One finding per lane, not per + # pair: an operator whose lane was split wants one message naming the lane, + # not a quadratic list of member pairs. Lanes are visited in manifest order, + # and a member absent from the derived index is left to the unknown-member + # class rather than counted here. + local position key resolved landed distinct + local -a members=() + for ((position = 0; position < PLA_EXPECTED_COUNT; position++)); do + resolved="" + landed="" + read -ra members <<<"${PLA_EXPECTED_MEMBERS[position]}" + for key in "${members[@]}"; do + [[ -n ${PLA_DERIVED_INDEX["$key"]+set} ]] || continue + resolved="$resolved $key" + landed="$landed ${PLA_DERIVED_INDEX["$key"]}" + done + distinct=$(pla_count_distinct "$landed") + ((distinct > 1)) || continue + pla_add_finding "$PLA_EXPECTED_TOGETHER_DERIVED_APART" \ + "expected component $(pla_component_label "$position") was derived apart: its members occupy $distinct distinct conflict components" \ + "$(pla_sort_numeric "$resolved")" + done +} + +pla_find_merged_lanes() { + # Append one finding per derived component spanning two asserted lanes. + # + # Reads PLA_COMPONENTS and PLA_EXPECTED_INDEX. A derived component touching + # two asserted lanes means derivation found contention between lanes asserted + # to be independent. Components are visited in derived order, and a key + # covered by no asserted lane is left to the uncovered-item class. + local index key covered lanes distinct component + local -a members=() + for ((index = 0; index < ${#PLA_COMPONENTS[@]}; index++)); do + component="${PLA_COMPONENTS[index]}" + covered="" + lanes="" + read -ra members <<<"$component" + for key in "${members[@]}"; do + [[ -n ${PLA_EXPECTED_INDEX["$key"]+set} ]] || continue + covered="$covered $key" + lanes="$lanes ${PLA_EXPECTED_INDEX["$key"]}" + done + distinct=$(pla_count_distinct "$lanes") + ((distinct > 1)) || continue + pla_add_finding "$PLA_EXPECTED_APART_DERIVED_TOGETHER" \ + "derived conflict component $(pla_render_member_list "$component") spans $distinct expected components that were asserted apart" \ + "$(pla_sort_numeric "$covered")" + done +} + +pla_build_indexes() { + # Populate the two flat key -> component-index lookups. + # + # The expected index is built in manifest order, so when one key appears in + # two asserted lanes the LAST occurrence wins. That is reproduced from the + # reference deliberately and no error is reported for it: duplicate membership + # across components is invariant M8's concern, and reporting it twice for one + # defect would misdescribe an advisory diagnostic. + local index position key + local -a members=() + PLA_DERIVED_INDEX=() + PLA_EXPECTED_INDEX=() + for ((index = 0; index < ${#PLA_COMPONENTS[@]}; index++)); do + read -ra members <<<"${PLA_COMPONENTS[index]}" + for key in "${members[@]}"; do + PLA_DERIVED_INDEX["$key"]="$index" + done + done + for ((position = 0; position < PLA_EXPECTED_COUNT; position++)); do + read -ra members <<<"${PLA_EXPECTED_MEMBERS[position]}" + for key in "${members[@]}"; do + PLA_EXPECTED_INDEX["$key"]="$position" + done + done +} + +pla_compare() { + # Compare the asserted lane grouping against the derived components. + # + # Args: $1 = space-separated declared item keys. Reads PLA_EXPECTED_* and + # PLA_EDGES; populates PLA_COMPONENTS and the three PLA_FINDING_* arrays. + # Findings are grouped by class in the fixed order split, merged, unknown + # member, uncovered item, so a reader sees the grouping disagreements before + # the authoring errors and the informational class last. + local keys="$1" key unknown="" uncovered="" index position + local -a members=() sorted=() + pla_derive_components "$keys" + pla_build_indexes + PLA_FINDING_KINDS=() + PLA_FINDING_DETAILS=() + PLA_FINDING_MEMBERS=() + + pla_find_split_lanes + pla_find_merged_lanes + + # An asserted member naming no manifest item is an authoring error in the + # assertion itself, so it is reported apart from a grouping disagreement. + # The candidate list is collected from the authored membership rather than + # by iterating the index, so no hash order reaches output. + for ((position = 0; position < PLA_EXPECTED_COUNT; position++)); do + read -ra members <<<"${PLA_EXPECTED_MEMBERS[position]}" + for key in "${members[@]}"; do + [[ -z ${PLA_DERIVED_INDEX["$key"]+set} ]] || continue + unknown="$unknown $key" + done + done + read -ra sorted <<<"$(pla_sort_numeric "$unknown" unique)" + for key in "${sorted[@]}"; do + pla_add_finding "$PLA_MEMBER_NAMES_NO_ITEM" \ + "expected member $key names no manifest item" "$key" + done + + # Informational only: an item the assertion did not mention, which is + # legitimate when the operator asserts a subset of the run. + for ((index = 0; index < ${#PLA_COMPONENTS[@]}; index++)); do + read -ra members <<<"${PLA_COMPONENTS[index]}" + for key in "${members[@]}"; do + [[ -z ${PLA_EXPECTED_INDEX["$key"]+set} ]] || continue + uncovered="$uncovered $key" + done + done + read -ra sorted <<<"$(pla_sort_numeric "$uncovered" unique)" + for key in "${sorted[@]}"; do + pla_add_finding "$PLA_ITEM_COVERED_BY_NO_COMPONENT" \ + "manifest item $key is covered by no expected component" "$key" + done +} + +# The report's closing line, stating that the diagnostic blocks nothing. Held +# as a constant so the entry point and the report share one spelling. +PLA_CLOSING_LINE="Advisory only: this diagnostic never blocks, never modifies a derived edge, never feeds compute_cohorts, and never influences scheduling." + +# The rendered report, newline separated and with no trailing newline. The +# single trailing newline is added by the entry point's printf. +PLA_REPORT="" + +pla_disagreement_count() { + # Echo the number of findings outside the informational class. + local index count=0 + for ((index = 0; index < ${#PLA_FINDING_KINDS[@]}; index++)); do + if ! pc_contains_word "$PLA_INFORMATIONAL_KINDS" "${PLA_FINDING_KINDS[index]}"; then + count=$((count + 1)) + fi + done + printf '%s' "$count" +} + +pla_format_report() { + # Render the comparison outcome into PLA_REPORT as advisory-only text. + # + # A header line naming the derived-component and disagreement counts, one + # ADVISORY-prefixed line per finding in emission order, and a closing line + # stating that the diagnostic blocks nothing. The disagreement count covers + # the first three classes only, because the informational class reports that + # the assertion was silent rather than that it disagreed. + local index disagreements + disagreements=$(pla_disagreement_count) + PLA_REPORT="Lane assertion: ${#PLA_COMPONENTS[@]} derived conflict component(s); $disagreements disagreement(s)." + for ((index = 0; index < ${#PLA_FINDING_KINDS[@]}; index++)); do + PLA_REPORT="$PLA_REPORT"$'\n'"ADVISORY [${PLA_FINDING_KINDS[index]}] ${PLA_FINDING_DETAILS[index]}." + done + PLA_REPORT="$PLA_REPORT"$'\n'"$PLA_CLOSING_LINE" +} diff --git a/.claude/lib/bash/report-lane-assertion.sh b/.claude/lib/bash/report-lane-assertion.sh new file mode 100644 index 000000000..79936e3fc --- /dev/null +++ b/.claude/lib/bash/report-lane-assertion.sh @@ -0,0 +1,169 @@ +#!/usr/bin/env bash +# report-lane-assertion.sh: destination-portable command-line entry point for +# the parallel surface's lane-assertion diagnostic. It exists so a workspace +# that received the Claude customization payload can compare a manifest's +# hand-authored `expected_conflict_components` assertion against the derived +# conflict components with nothing but bash -- no Python, no Poetry, no +# repository checkout. +# +# Usage: +# bash .claude/lib/bash/report-lane-assertion.sh --manifest \ +# [--edges ": : ..."] +# +# `--edges` is optional; omitting it, or passing an empty string, means the +# derived conflict graph has no edges and every declared item is its own +# component. +# +# Output contract: +# stdout the advisory report: a header line, one ADVISORY line per finding, +# and the closing line, byte identical to the Python authority +# scripts/dev_tools/parallel_lane_assertion.py +# stderr usage text on a usage error +# exit 0 every non-usage path, including a disagreement, an unreadable +# manifest, an unparseable manifest, and an out-of-subset manifest +# exit 2 usage error only +# +# The diagnostic is ADVISORY. A disagreement is never expressible as a non-zero +# exit status, because a planner must not be blocked by a hand-authored +# assertion that derivation contradicts. +set -euo pipefail + +# Resolve this script's own directory so the library sources regardless of cwd. +RLA_SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=.claude/lib/bash/parallel-lane-assertion.sh +# shellcheck disable=SC1091 +source "$RLA_SCRIPT_DIR/parallel-lane-assertion.sh" + +pc_enforce_c_locale + +rla_usage() { + # Print the entry point's usage text. + cat <<'EOF' +Usage: report-lane-assertion.sh --manifest [--edges ": ..."] + +Compares a parallel manifest's expected_conflict_components assertion against +the derived conflict components and prints an advisory report on stdout. + +Options: + --manifest Path to docs/features/parallel//parallel.md (required). + --edges Derived conflict edges as ": :" (optional). + +Advisory only: the report never blocks. Every non-usage path exits 0. +EOF +} + +rla_manifest_unreadable_detail() { + # Echo the reason the manifest could not be read, or nothing when it can. + # + # Args: $1 = the manifest path. The Python authority prints str(OSError) + # here, which names an errno string bash cannot reproduce; that is declared + # divergence class 4, so only the line's prefix is parity scoped and this + # detail is deliberately a bash-native phrasing rather than an imitation. + local path="$1" + if [[ ! -e $path ]]; then + printf 'no such file: %s' "$path" + elif [[ -d $path ]]; then + printf 'path is a directory: %s' "$path" + elif [[ ! -r $path ]]; then + printf 'file is not readable: %s' "$path" + fi +} + +rla_report() { + # Run the comparison over an already-parsed node table and print the report. + # + # The node table must already be populated by pm_parse_manifest. Args: + # $1 = the raw --edges value. + pla_read_manifest_inputs + pla_parse_edges "$1" + pla_compare "$PLA_ITEM_KEYS" + pla_format_report + # One printf with one trailing newline, matching the single print() the + # Python authority issues, so the two lanes agree byte for byte. + printf '%s\n' "$PLA_REPORT" +} + +rla_main() { + # Parse arguments, read the manifest, and print the advisory report. + # + # Exit 2 is reserved for a usage error -- an unknown flag, a flag missing its + # value, or an absent --manifest -- and its usage text goes to stderr so it + # cannot be mistaken for a report. --help is a successful request for the + # same text, so it goes to stdout and exits 0. Every other path exits 0, + # because the diagnostic is advisory and a verdict must not be expressible + # as a non-zero status. + local manifest="" edges="" manifest_seen=0 text + while (($# > 0)); do + case "$1" in + --manifest) + (($# >= 2)) || { + rla_usage >&2 + return 2 + } + manifest="$2" + manifest_seen=1 + shift 2 + ;; + --edges) + (($# >= 2)) || { + rla_usage >&2 + return 2 + } + edges="$2" + shift 2 + ;; + --help | -h) + rla_usage + return 0 + ;; + *) + rla_usage >&2 + return 2 + ;; + esac + done + ((manifest_seen == 1)) || { + rla_usage >&2 + return 2 + } + + # An unreadable manifest is reported and exits 0, not 2: the operator asked + # for a diagnostic and the diagnostic's answer is that it could not look. + local unreadable + unreadable=$(rla_manifest_unreadable_detail "$manifest") + if [[ -n $unreadable ]]; then + printf 'Lane assertion: manifest unreadable (%s); no comparison made.\n' "$unreadable" + return 0 + fi + + text=$(cat -- "$manifest") + pc_errors_reset + local parse_status=0 + pm_parse_manifest "$text" || parse_status=$? + if ((parse_status == 2)); then + # Status 2 is the scanner's refusal to model a construct. It is reported + # on its own line, distinct from the unparseable line, because a refusal + # is not a verdict about the manifest: the Python authority would parse + # this document, and saying so is more useful than a guessed answer. + printf 'Lane assertion: manifest outside the supported YAML subset (%s); no comparison made.\n' \ + "$PM_SUBSET_DETAIL" + return 0 + fi + if ((parse_status == 1)); then + # The M1 message is reused byte for byte from pm_parse_manifest rather + # than restated here, so the two lanes cannot drift on its wording. + printf 'Lane assertion: manifest unparseable (%s).\n' "${PC_ERRORS[0]}" + return 0 + fi + + rla_report "$edges" + return 0 +} + +# Guard so the file can be sourced without executing main. main's return code +# is captured and re-exited explicitly as the final statement. +if [[ ${BASH_SOURCE[0]} == "${0}" ]]; then + rla_rc=0 + rla_main "$@" || rla_rc=$? + exit "$rla_rc" +fi diff --git a/.claude/lib/blast-radius/BlastRadius.psm1 b/.claude/lib/blast-radius/BlastRadius.psm1 index 65f5ae0c3..e081eabb4 100644 --- a/.claude/lib/blast-radius/BlastRadius.psm1 +++ b/.claude/lib/blast-radius/BlastRadius.psm1 @@ -47,15 +47,17 @@ - Two empty radii, and an empty radius against a non-empty one, do not conflict. Under-reporting via emptiness is V1's problem at plan time, not the relation's. + CONVENTION: this module fails fast at module scope and imports its siblings with -ErrorAction Stop. #> Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' -Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusExtraction.psm1') -Force -Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusGlob.psm1') -Force -Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusConfig.psm1') -Force -Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusNormalization.psm1') -Force -Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusValidation.psm1') -Force +Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusExtraction.psm1') -Force -ErrorAction Stop +Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusGlob.psm1') -Force -ErrorAction Stop +Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusConfig.psm1') -Force -ErrorAction Stop +Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusNormalization.psm1') -Force -ErrorAction Stop +Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusValidation.psm1') -Force -ErrorAction Stop # Feature-folder handling. Every radius contains its own feature folder, and a # caller may pass either a bare folder name or an already-qualified path. diff --git a/.claude/lib/blast-radius/BlastRadiusConfig.psm1 b/.claude/lib/blast-radius/BlastRadiusConfig.psm1 index 5cb6cd1fa..a41ce7059 100644 --- a/.claude/lib/blast-radius/BlastRadiusConfig.psm1 +++ b/.claude/lib/blast-radius/BlastRadiusConfig.psm1 @@ -28,12 +28,14 @@ rely on the PSCustomObject conversion this module performs; no other shape is accepted. - Every returned collection is deduplicated and ordinally sorted. + CONVENTION: this module fails fast at module scope and imports its siblings with -ErrorAction Stop. #> Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' -Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusExtraction.psm1') -Force -Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusGlob.psm1') -Force +Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusExtraction.psm1') -Force -ErrorAction Stop +Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusGlob.psm1') -Force -ErrorAction Stop # Keys read from the parsed config/blast-radius.json truth table. $script:ConfigSharedSurfaceKey = 'shared_surfaces' diff --git a/.claude/lib/blast-radius/BlastRadiusExtraction.psm1 b/.claude/lib/blast-radius/BlastRadiusExtraction.psm1 index 2791e5e02..2a01758e8 100644 --- a/.claude/lib/blast-radius/BlastRadiusExtraction.psm1 +++ b/.claude/lib/blast-radius/BlastRadiusExtraction.psm1 @@ -33,16 +33,18 @@ - Every returned collection is deduplicated and ordinally sorted via [StringComparer]::Ordinal, so identical inputs produce identical output in both languages regardless of the current culture. + CONVENTION: this module fails fast at module scope and imports its siblings with -ErrorAction Stop. #> Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' # Get-OrdinalSortedEntry moved to BlastRadiusGlob.psm1, where its sibling ordinal # primitive Get-OrdinalSmallestEntry already lives, so this module stays within # the 500-line limit (issue #452). The import keeps every pre-existing call site # and test source-compatible, and introduces no cycle because the Glob module # imports no sibling. -Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusGlob.psm1') -Force +Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusGlob.psm1') -Force -ErrorAction Stop # Test-MultipleFeatureFolderSpan moved to BlastRadiusTokenShape.psm1, joining the # new Test-PlaceholderMarker predicate that could not be added here: this module @@ -52,7 +54,7 @@ Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusGlob.psm1') # introduces no cycle, because the TokenShape module imports no sibling. This # follows the same re-import-and-re-export pattern used above for the relocated # ordinal-sort helper. -Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusTokenShape.psm1') -Force +Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusTokenShape.psm1') -Force -ErrorAction Stop # Plan-structure patterns. The regex text mirrors the Python constants so radius # derivation and the plan validator can never disagree about which lines are diff --git a/.claude/lib/blast-radius/BlastRadiusGlob.psm1 b/.claude/lib/blast-radius/BlastRadiusGlob.psm1 index cb2e5d729..c7c4a86cd 100644 --- a/.claude/lib/blast-radius/BlastRadiusGlob.psm1 +++ b/.claude/lib/blast-radius/BlastRadiusGlob.psm1 @@ -31,9 +31,11 @@ newline in .NET. - Comparisons and ordering use [StringComparer]::Ordinal and [string]::CompareOrdinal so results do not vary with the current culture. + CONVENTION: this module fails fast at module scope and imports its siblings with -ErrorAction Stop. #> Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' # Wildcards that make a path entry a pattern rather than a file. The question # mark is included because the subsumption helper treats it as a pattern; diff --git a/.claude/lib/blast-radius/BlastRadiusNormalization.psm1 b/.claude/lib/blast-radius/BlastRadiusNormalization.psm1 index bbc69567c..3f53e539a 100644 --- a/.claude/lib/blast-radius/BlastRadiusNormalization.psm1 +++ b/.claude/lib/blast-radius/BlastRadiusNormalization.psm1 @@ -27,13 +27,15 @@ - An empty mandate-read collection excludes nothing, so a truth table with no mandate_reads key reproduces pre-change behaviour exactly. - Every returned collection is deduplicated and ordinally sorted. + CONVENTION: this module fails fast at module scope and imports its siblings with -ErrorAction Stop. #> Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' -Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusExtraction.psm1') -Force -Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusGlob.psm1') -Force -Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusConfig.psm1') -Force +Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusExtraction.psm1') -Force -ErrorAction Stop +Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusGlob.psm1') -Force -ErrorAction Stop +Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusConfig.psm1') -Force -ErrorAction Stop # Markdown ATX heading pattern used to locate spec interface sections. Relocated # with Get-ContractIdentifier: $script: scope is per module, so the variable must diff --git a/.claude/lib/blast-radius/BlastRadiusTokenShape.psm1 b/.claude/lib/blast-radius/BlastRadiusTokenShape.psm1 index ace189fd7..2abd2c048 100644 --- a/.claude/lib/blast-radius/BlastRadiusTokenShape.psm1 +++ b/.claude/lib/blast-radius/BlastRadiusTokenShape.psm1 @@ -49,9 +49,11 @@ throws for any input, because the classifier that calls them runs over every inline-code span in a document and a throw would abort an entire derivation over one stray span. + CONVENTION: this module fails fast at module scope and imports its siblings with -ErrorAction Stop. #> Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' # Placeholder and interpolation markers. A token carrying any of these was # written to document a shape, not to name a file, so it can never be a write diff --git a/.claude/lib/blast-radius/BlastRadiusValidation.psm1 b/.claude/lib/blast-radius/BlastRadiusValidation.psm1 index d57e3dfc1..e0935d1f2 100644 --- a/.claude/lib/blast-radius/BlastRadiusValidation.psm1 +++ b/.claude/lib/blast-radius/BlastRadiusValidation.psm1 @@ -30,14 +30,16 @@ explicitly because the Python reference rejects it. - Finding message text is a contract literal shared with the Python reference and the cross-language fixture corpus; do not reword it. + CONVENTION: this module fails fast at module scope and imports its siblings with -ErrorAction Stop. #> Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' -Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusExtraction.psm1') -Force -Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusGlob.psm1') -Force -Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusConfig.psm1') -Force -Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusNormalization.psm1') -Force +Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusExtraction.psm1') -Force -ErrorAction Stop +Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusGlob.psm1') -Force -ErrorAction Stop +Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusConfig.psm1') -Force -ErrorAction Stop +Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusNormalization.psm1') -Force -ErrorAction Stop # Finding vocabulary. These strings are contract literals consumed by the # downstream parallel schema and planner features. diff --git a/.claude/lib/codex-routing/CodexDeployment.psm1 b/.claude/lib/codex-routing/CodexDeployment.psm1 index 04c85941e..e23a53a0e 100644 --- a/.claude/lib/codex-routing/CodexDeployment.psm1 +++ b/.claude/lib/codex-routing/CodexDeployment.psm1 @@ -36,9 +36,11 @@ The function is pure: it reads no file, starts no process, and never mutates its input. + CONVENTION: this module fails fast at module scope and imports its siblings with -ErrorAction Stop. #> Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' # The complexity-band vocabulary, ordered lowest to highest. Pinned to BAND_ORDER # in scripts/dev_tools/compute_complexity_floor.py. diff --git a/.claude/lib/codex-routing/CodexTopology.psm1 b/.claude/lib/codex-routing/CodexTopology.psm1 index 70da608cb..2e0d98475 100644 --- a/.claude/lib/codex-routing/CodexTopology.psm1 +++ b/.claude/lib/codex-routing/CodexTopology.psm1 @@ -47,9 +47,11 @@ The function is pure: it reads no file, starts no process, and never mutates its input. + CONVENTION: this module fails fast at module scope and imports its siblings with -ErrorAction Stop. #> Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' # The three permitted execution contexts, the sorted tuple rendering the Python # message interpolates, and the two contexts that mark epic child work. diff --git a/.claude/lib/discovery-validation/DiscoveryValidation.psm1 b/.claude/lib/discovery-validation/DiscoveryValidation.psm1 index d90783a46..3f91d9ff5 100644 --- a/.claude/lib/discovery-validation/DiscoveryValidation.psm1 +++ b/.claude/lib/discovery-validation/DiscoveryValidation.psm1 @@ -29,29 +29,29 @@ OR on non-empty output, so the previous Python CLI's success line (" validation passed: ") turned a PASSING validation into a DENY. - PARITY with `validate_discovery_profile.py` / `validate_discovery_schema_artifacts.py`. - Error families are preserved: `invalid JSON (...)`, - `JSON root must be an object for validation`, `schema resolution failed (...)`, - `Profile document is empty.`, `Profile document root must be a mapping.`, - `Missing required field: .` Schema location resolves solely from each - artifact's own `$schema`, as in the reference; the type-to-file table below is - documentation and artifact-type validation only. Deliberate divergences: an - `http(s)://` `$schema` is NOT fetched (no guaranteed destination network, so a - non-`file` scheme is reported fail-closed in the `schema resolution failed (...)` - family), and `file://` resolves through `[uri]::LocalPath` rather than the - reference's `Path(parsed.path)`, which mishandles a Windows `file:///C:/...` path. - Unavoidable divergence: per-violation wording comes from `Test-Json`, not - `jsonschema`, so violation strings differ while family and verdict match. The - profile check reproduces the placeholder contract without a YAML parser (PowerShell - ships none); see `Get-DiscoveryProfileValidationError` for what is and is not - detected. + PARITY with `validate_discovery_profile.py` / `validate_discovery_schema_artifacts.py`. Error + families are preserved: `invalid JSON (...)`, `JSON root must be an object for validation`, + `schema resolution failed (...)`, `Profile document is empty.`, + `Profile document root must be a mapping.`, `Missing required field: .` Schema + location resolves solely from each artifact's own `$schema`, as in the reference; the + type-to-file table below is documentation and artifact-type validation only. Deliberate + divergences: an `http(s)://` `$schema` is NOT fetched (no guaranteed destination network, + so a non-`file` scheme is reported fail-closed in the `schema resolution failed (...)` + family), and `file://` resolves through `[uri]::LocalPath` rather than the reference's + `Path(parsed.path)`, which mishandles a Windows `file:///C:/...` path. Unavoidable + divergence: per-violation wording comes from `Test-Json`, not `jsonschema`, so violation + strings differ while family and verdict match. The profile check reproduces the + placeholder contract without a YAML parser (PowerShell ships none); see + `Get-DiscoveryProfileValidationError` for what is and is not detected. .EXAMPLE Invoke-DiscoveryArtifactValidation -ValidatorArgs @('evidence-reference', $path) Returns @{ ExitCode = 0; Output = '' } when the artifact conforms. + CONVENTION: this module fails fast at module scope and imports its siblings with -ErrorAction Stop. #> Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' # Schema-governed artifact types mapped to their filenames under `schemas/discovery/v1/`. # Verified against `validate_discovery_artifacts.py`: `runtime-scenario`, diff --git a/.claude/lib/hook-payload/HookPayload.psm1 b/.claude/lib/hook-payload/HookPayload.psm1 index 3e56685fb..92c46d459 100644 --- a/.claude/lib/hook-payload/HookPayload.psm1 +++ b/.claude/lib/hook-payload/HookPayload.psm1 @@ -37,9 +37,11 @@ Compatible with PowerShell 7+. No external module dependencies, no filesystem access, no subprocess, no network, and no wall-clock read. Mirrored byte-identically under extensions/drm-copilot/resources/claude-customizations/. + CONVENTION: this module fails fast at module scope and imports its siblings with -ErrorAction Stop. #> Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' # Anomaly codes. These are the only values Get-ClaudeHookPayloadAnomalyReason maps. $script:AnomalyEmptyPayload = 'EmptyPayload' diff --git a/.claude/lib/mermaid/MermaidGrammar.psm1 b/.claude/lib/mermaid/MermaidGrammar.psm1 index 3bf94c213..eadabbfc4 100644 --- a/.claude/lib/mermaid/MermaidGrammar.psm1 +++ b/.claude/lib/mermaid/MermaidGrammar.psm1 @@ -28,9 +28,11 @@ policy for free-text and plugin-backed grammars. Brackets are structural only where they delimit node shapes or attribute blocks: a gantt task named `Deploy (phase 1` must never be blocked as unbalanced. + CONVENTION: this module fails fast at module scope and imports its siblings with -ErrorAction Stop. #> Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' $script:MermaidGrammarVersion = '11.17.0' $script:MermaidGrammarSourceUrl = 'https://mermaid.js.org/intro/syntax-reference.html' diff --git a/.claude/lib/mermaid/MermaidLineScanner.psm1 b/.claude/lib/mermaid/MermaidLineScanner.psm1 index 5aab2f3a6..79c558576 100644 --- a/.claude/lib/mermaid/MermaidLineScanner.psm1 +++ b/.claude/lib/mermaid/MermaidLineScanner.psm1 @@ -30,11 +30,13 @@ Pinned to Mermaid 11.17.0 through MermaidGrammar.psm1. Every function is pure: no filesystem, subprocess, network, or wall-clock access, and no input is mutated. + CONVENTION: this module fails fast at module scope and imports its siblings with -ErrorAction Stop. #> Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' -Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'MermaidGrammar.psm1') -Force +Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'MermaidGrammar.psm1') -Force -ErrorAction Stop # Arrow-token affix tables. An arrow candidate is a run of `-`, `=`, `.`, or `~` # (the "core") optionally extended by one of these affixes on either side. The diff --git a/.claude/lib/mermaid/MermaidMarkdownFences.psm1 b/.claude/lib/mermaid/MermaidMarkdownFences.psm1 index 9ffb41e79..f1189071a 100644 --- a/.claude/lib/mermaid/MermaidMarkdownFences.psm1 +++ b/.claude/lib/mermaid/MermaidMarkdownFences.psm1 @@ -32,9 +32,11 @@ Pinned to Mermaid 11.17.0 through MermaidGrammar.psm1. Every function is pure: no filesystem, subprocess, network, or wall-clock access, and no input is mutated. + CONVENTION: this module fails fast at module scope and imports its siblings with -ErrorAction Stop. #> Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' # The marker text must be exactly `mermaid-validator: ignore` and is # case-sensitive. Whitespace is permitted around the line, around the comment diff --git a/.claude/lib/mermaid/MermaidValidation.psm1 b/.claude/lib/mermaid/MermaidValidation.psm1 index d266ebe3c..c32e6f1ba 100644 --- a/.claude/lib/mermaid/MermaidValidation.psm1 +++ b/.claude/lib/mermaid/MermaidValidation.psm1 @@ -37,13 +37,15 @@ no filesystem, subprocess, network, or wall-clock access, and no input is mutated. CRLF, CR, and LF inputs produce identical verdicts because line splitting is normalized once in MermaidMarkdownFences.psm1. + CONVENTION: this module fails fast at module scope and imports its siblings with -ErrorAction Stop. #> Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' -Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'MermaidGrammar.psm1') -Force -Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'MermaidLineScanner.psm1') -Force -Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'MermaidMarkdownFences.psm1') -Force +Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'MermaidGrammar.psm1') -Force -ErrorAction Stop +Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'MermaidLineScanner.psm1') -Force -ErrorAction Stop +Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'MermaidMarkdownFences.psm1') -Force -ErrorAction Stop function Get-MermaidFinding { <# diff --git a/.claude/lib/model-routing/ModelRouting.psm1 b/.claude/lib/model-routing/ModelRouting.psm1 index a1c06c519..5b5ece987 100644 --- a/.claude/lib/model-routing/ModelRouting.psm1 +++ b/.claude/lib/model-routing/ModelRouting.psm1 @@ -18,9 +18,11 @@ 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 two-language mirror; it never imports validator logic. + CONVENTION: this module fails fast at module scope and imports its siblings with -ErrorAction Stop. #> Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' # The fixed complexity-band vocabulary, ordered from lowest to highest rigor. # The array order defines "higher" and "lower" band comparisons used by the diff --git a/.claude/lib/orchestrator-state/OrchestratorState.psm1 b/.claude/lib/orchestrator-state/OrchestratorState.psm1 index 7d9528804..49ec3c3c1 100644 --- a/.claude/lib/orchestrator-state/OrchestratorState.psm1 +++ b/.claude/lib/orchestrator-state/OrchestratorState.psm1 @@ -27,9 +27,11 @@ (Invoke-OrchestratorStatePreflight) consumed by .claude/hooks/enforce-pr-author-skill.ps1. Its default seam runs the portable in-process validation and names no interpreter on any code path. + CONVENTION: this module fails fast at module scope and imports its siblings with -ErrorAction Stop. #> Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' # The canonical top-level checkpoint keys required by the primary validator. # Pinned to REQUIRED_STATE_KEYS in scripts/dev_tools/validate_orchestrator_state.py. @@ -139,6 +141,15 @@ function Get-OrchestratorStateCheckpoint { - Ok (bool): $true when the file exists and parsed to a JSON object. - State (object): the parsed PSCustomObject on success, otherwise $null. - Error (string): a non-empty failure message on failure, otherwise ''. + .NOTES + VALUE CONTRACT. An ISO-8601-valued key in the returned State is date-coerced by ConvertFrom-Json + into System.DateTime, not the string the checkpoint holds. The module-declared ISO-8601 key + families are last_updated, started_at, completed_at, and verified_at. Every current validation + is presence-only, so the coercion is unobservable today. Two future exposures make it worth + stating: value comparison in OrchestratorStateCheckpointValue.psm1, which reports a mismatch + when one side is a string and the other is not, and the [string] $ComputedAt binding in + BlastRadius.psm1, which would coerce a DateTime to a culture-dependent string with no error. + A post-parse repair back to a string is PROHIBITED as lossy in both offset and format. #> [CmdletBinding()] [OutputType([hashtable])] diff --git a/.claude/lib/orchestrator-state/OrchestratorStateCheckpointValue.psm1 b/.claude/lib/orchestrator-state/OrchestratorStateCheckpointValue.psm1 index 4ce6a25b0..1b0d00c69 100644 --- a/.claude/lib/orchestrator-state/OrchestratorStateCheckpointValue.psm1 +++ b/.claude/lib/orchestrator-state/OrchestratorStateCheckpointValue.psm1 @@ -23,9 +23,11 @@ Every function is pure: it reads no file, starts no process, and never mutates its input. The module imports nothing, so it is the leaf of the parity family's import graph and cannot participate in a load-order cycle. + CONVENTION: this module fails fast at module scope and imports its siblings with -ErrorAction Stop. #> Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' # The numeric CLR types a JSON number can deserialize to, used by the Python # zero-equivalence predicate so a value is compared numerically, not by type. diff --git a/.claude/lib/orchestrator-state/OrchestratorStateCodexModelReceipts.psm1 b/.claude/lib/orchestrator-state/OrchestratorStateCodexModelReceipts.psm1 index 551039b9c..40a7da67c 100644 --- a/.claude/lib/orchestrator-state/OrchestratorStateCodexModelReceipts.psm1 +++ b/.claude/lib/orchestrator-state/OrchestratorStateCodexModelReceipts.psm1 @@ -22,16 +22,18 @@ Every function is pure: it reads no file, starts no process, and never mutates its input. + CONVENTION: this module fails fast at module scope and imports its siblings with -ErrorAction Stop. #> Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' # Import the shared checkpoint-value primitives and the single Codex deployment # resolver, resolved relative to this module's directory so both imports travel # with the pushed-down pack regardless of the working directory. -Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'OrchestratorStateCheckpointValue.psm1') -Force +Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'OrchestratorStateCheckpointValue.psm1') -Force -ErrorAction Stop $script:CodexDeploymentModulePath = Join-Path -Path (Join-Path -Path $PSScriptRoot -ChildPath '..') -ChildPath (Join-Path -Path 'codex-routing' -ChildPath 'CodexDeployment.psm1') -Import-Module $script:CodexDeploymentModulePath -Force +Import-Module $script:CodexDeploymentModulePath -Force -ErrorAction Stop # The checkpoint key this family validates. $script:CODEX_MODEL_ROUTING_RECEIPTS_KEY = 'codex_model_routing_receipts' diff --git a/.claude/lib/orchestrator-state/OrchestratorStateCodexTopologyReceipts.psm1 b/.claude/lib/orchestrator-state/OrchestratorStateCodexTopologyReceipts.psm1 index 5a1feb1d5..ba971ffc8 100644 --- a/.claude/lib/orchestrator-state/OrchestratorStateCodexTopologyReceipts.psm1 +++ b/.claude/lib/orchestrator-state/OrchestratorStateCodexTopologyReceipts.psm1 @@ -25,16 +25,18 @@ Every function is pure: it reads no file, starts no process, and never mutates its input. + CONVENTION: this module fails fast at module scope and imports its siblings with -ErrorAction Stop. #> Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' # Import the shared checkpoint-value primitives and the single Codex topology # resolver, resolved relative to this module's directory so both imports travel # with the pushed-down pack regardless of the working directory. -Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'OrchestratorStateCheckpointValue.psm1') -Force +Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'OrchestratorStateCheckpointValue.psm1') -Force -ErrorAction Stop $script:CodexTopologyModulePath = Join-Path -Path (Join-Path -Path $PSScriptRoot -ChildPath '..') -ChildPath (Join-Path -Path 'codex-routing' -ChildPath 'CodexTopology.psm1') -Import-Module $script:CodexTopologyModulePath -Force +Import-Module $script:CodexTopologyModulePath -Force -ErrorAction Stop # The checkpoint key this family validates. $script:CODEX_TOPOLOGY_RECEIPTS_KEY = 'codex_topology_receipts' diff --git a/.claude/lib/orchestrator-state/OrchestratorStateCompletion.psm1 b/.claude/lib/orchestrator-state/OrchestratorStateCompletion.psm1 index 31bccb21d..efab0dcbe 100644 --- a/.claude/lib/orchestrator-state/OrchestratorStateCompletion.psm1 +++ b/.claude/lib/orchestrator-state/OrchestratorStateCompletion.psm1 @@ -44,21 +44,23 @@ receipt still yields error text containing the literal token `model_routing_receipts`, so the completion hook maps the failure to its `MODEL_ROUTING_BLOCKED:` block reason. + CONVENTION: this module fails fast at module scope and imports its siblings with -ErrorAction Stop. #> Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' # Import the sibling shared module, the portable model-routing formulas, and the # ported check families, resolved relative to this module's directory so every # import travels with the pushed-down pack regardless of the working directory. -Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'OrchestratorState.psm1') -Force +Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'OrchestratorState.psm1') -Force -ErrorAction Stop $script:ModelRoutingModulePath = Join-Path -Path (Join-Path -Path $PSScriptRoot -ChildPath '..') -ChildPath (Join-Path -Path 'model-routing' -ChildPath 'ModelRouting.psm1') -Import-Module $script:ModelRoutingModulePath -Force -Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'OrchestratorStateCheckpointValue.psm1') -Force -Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'OrchestratorStateModelReceipts.psm1') -Force -Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'OrchestratorStateUnconditional.psm1') -Force -Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'OrchestratorStateCompletionChecks.psm1') -Force -Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'OrchestratorStateRoutingContract.psm1') -Force +Import-Module $script:ModelRoutingModulePath -Force -ErrorAction Stop +Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'OrchestratorStateCheckpointValue.psm1') -Force -ErrorAction Stop +Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'OrchestratorStateModelReceipts.psm1') -Force -ErrorAction Stop +Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'OrchestratorStateUnconditional.psm1') -Force -ErrorAction Stop +Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'OrchestratorStateCompletionChecks.psm1') -Force -ErrorAction Stop +Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'OrchestratorStateRoutingContract.psm1') -Force -ErrorAction Stop # The two optional keys the M3 leg re-validates, guarded on key presence so an # absent key does not emit a spurious "must be a list when present" error. diff --git a/.claude/lib/orchestrator-state/OrchestratorStateCompletionChecks.psm1 b/.claude/lib/orchestrator-state/OrchestratorStateCompletionChecks.psm1 index eec15a287..4d2edc4cb 100644 --- a/.claude/lib/orchestrator-state/OrchestratorStateCompletionChecks.psm1 +++ b/.claude/lib/orchestrator-state/OrchestratorStateCompletionChecks.psm1 @@ -33,15 +33,17 @@ Every function is pure: it reads no file, starts no process, and never mutates its input. + CONVENTION: this module fails fast at module scope and imports its siblings with -ErrorAction Stop. #> Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' # Import the shared checkpoint-value primitives and the pinned routing matrix, # resolved relative to this module's directory so both imports travel with the # pushed-down pack regardless of the working directory. -Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'OrchestratorStateCheckpointValue.psm1') -Force -Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'OrchestratorStateRoutingMatrix.psm1') -Force +Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'OrchestratorStateCheckpointValue.psm1') -Force -ErrorAction Stop +Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'OrchestratorStateRoutingMatrix.psm1') -Force -ErrorAction Stop # The six step-status keys, in report order. $script:STEP_STATUS_KEYS = @( diff --git a/.claude/lib/orchestrator-state/OrchestratorStateModelReceipts.psm1 b/.claude/lib/orchestrator-state/OrchestratorStateModelReceipts.psm1 index 9a61d3c56..2759a6bc7 100644 --- a/.claude/lib/orchestrator-state/OrchestratorStateModelReceipts.psm1 +++ b/.claude/lib/orchestrator-state/OrchestratorStateModelReceipts.psm1 @@ -19,16 +19,18 @@ Every function is pure: it reads no file, starts no process, and never mutates its input. Each check returns a string array, empty when the block is valid. + CONVENTION: this module fails fast at module scope and imports its siblings with -ErrorAction Stop. #> Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' # Import the shared checkpoint-value primitives and the two reference formulas, # resolved relative to this module's directory so the imports travel with the # pushed-down pack regardless of the consumer repository's working directory. -Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'OrchestratorStateCheckpointValue.psm1') -Force +Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'OrchestratorStateCheckpointValue.psm1') -Force -ErrorAction Stop $script:ModelRoutingModulePath = Join-Path -Path (Join-Path -Path $PSScriptRoot -ChildPath '..') -ChildPath (Join-Path -Path 'model-routing' -ChildPath 'ModelRouting.psm1') -Import-Module $script:ModelRoutingModulePath -Force +Import-Module $script:ModelRoutingModulePath -Force -ErrorAction Stop # The complexity-band vocabulary, ordered lowest to highest. Pinned to BAND_ORDER # in scripts/dev_tools/compute_complexity_floor.py and to the identical ordering diff --git a/.claude/lib/orchestrator-state/OrchestratorStateReceipts.psm1 b/.claude/lib/orchestrator-state/OrchestratorStateReceipts.psm1 index ea5a6a4ce..c4000931f 100644 --- a/.claude/lib/orchestrator-state/OrchestratorStateReceipts.psm1 +++ b/.claude/lib/orchestrator-state/OrchestratorStateReceipts.psm1 @@ -27,14 +27,16 @@ Every function is pure: it reads no file, starts no process, and never mutates its input. Each check returns a string array, empty when the block is valid. + CONVENTION: this module fails fast at module scope and imports its siblings with -ErrorAction Stop. #> Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' # Import the shared checkpoint-value primitives, resolved relative to this # module's directory so the import travels with the pushed-down pack regardless # of the consumer repository's working directory. -Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'OrchestratorStateCheckpointValue.psm1') -Force +Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'OrchestratorStateCheckpointValue.psm1') -Force -ErrorAction Stop # The eight keys every list-form delegation receipt must carry. Pinned to # REQUIRED_RECEIPT_KEYS in scripts/dev_tools/validate_orchestrator_state.py. diff --git a/.claude/lib/orchestrator-state/OrchestratorStateRoutingContract.psm1 b/.claude/lib/orchestrator-state/OrchestratorStateRoutingContract.psm1 index 92f8a5823..7337be4a7 100644 --- a/.claude/lib/orchestrator-state/OrchestratorStateRoutingContract.psm1 +++ b/.claude/lib/orchestrator-state/OrchestratorStateRoutingContract.psm1 @@ -45,15 +45,17 @@ Every function is pure: it reads no file, starts no process, and never mutates its input. + CONVENTION: this module fails fast at module scope and imports its siblings with -ErrorAction Stop. #> Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' # Import the shared checkpoint-value primitives and the pinned routing matrix, # resolved relative to this module's directory so both imports travel with the # pushed-down pack regardless of the working directory. -Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'OrchestratorStateCheckpointValue.psm1') -Force -Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'OrchestratorStateRoutingMatrix.psm1') -Force +Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'OrchestratorStateCheckpointValue.psm1') -Force -ErrorAction Stop +Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'OrchestratorStateRoutingMatrix.psm1') -Force -ErrorAction Stop # The promotion-entry MCP tools and the promotion type that triggers substitution. $script:FEATURE_PROMOTION_ENTRY_TOOL = 'new_potential_entry' diff --git a/.claude/lib/orchestrator-state/OrchestratorStateRoutingMatrix.psm1 b/.claude/lib/orchestrator-state/OrchestratorStateRoutingMatrix.psm1 index cf9318c7e..8b4f97806 100644 --- a/.claude/lib/orchestrator-state/OrchestratorStateRoutingMatrix.psm1 +++ b/.claude/lib/orchestrator-state/OrchestratorStateRoutingMatrix.psm1 @@ -33,13 +33,15 @@ Every function is pure: it reads no file, starts no process, and never mutates its input. + CONVENTION: this module fails fast at module scope and imports its siblings with -ErrorAction Stop. #> Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' # Import the shared checkpoint-value primitives, resolved relative to this # module's directory so the import travels with the pushed-down pack. -Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'OrchestratorStateCheckpointValue.psm1') -Force +Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'OrchestratorStateCheckpointValue.psm1') -Force -ErrorAction Stop # The pinned routing-matrix subset. Each route records the two gate flags and the # three required-name lists the completion checks consume. A gate flag of $null diff --git a/.claude/lib/orchestrator-state/OrchestratorStateUnconditional.psm1 b/.claude/lib/orchestrator-state/OrchestratorStateUnconditional.psm1 index c056ecc7e..39e2c5fd7 100644 --- a/.claude/lib/orchestrator-state/OrchestratorStateUnconditional.psm1 +++ b/.claude/lib/orchestrator-state/OrchestratorStateUnconditional.psm1 @@ -37,20 +37,22 @@ The function is pure: it reads no file, starts no process, and never mutates its input. + CONVENTION: this module fails fast at module scope and imports its siblings with -ErrorAction Stop. #> Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' # Import the four leaf check modules eagerly. None of them imports this module, # so this import graph has no cycle. OrchestratorState.psm1 is deliberately NOT # imported here; it is loaded lazily inside the function, because the preflight # path in that module imports this one and an eager import in both directions # would couple their load order. -Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'OrchestratorStateReceipts.psm1') -Force -Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'OrchestratorStateModelReceipts.psm1') -Force -Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'OrchestratorStateCodexModelReceipts.psm1') -Force -Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'OrchestratorStateCodexTopologyReceipts.psm1') -Force -Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'OrchestratorStateCheckpointValue.psm1') -Force +Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'OrchestratorStateReceipts.psm1') -Force -ErrorAction Stop +Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'OrchestratorStateModelReceipts.psm1') -Force -ErrorAction Stop +Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'OrchestratorStateCodexModelReceipts.psm1') -Force -ErrorAction Stop +Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'OrchestratorStateCodexTopologyReceipts.psm1') -Force -ErrorAction Stop +Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'OrchestratorStateCheckpointValue.psm1') -Force -ErrorAction Stop # The checkpoint key whose value carries the delegation receipts. It is read with # the Python `is not None` guard rather than a presence guard, matching the diff --git a/.claude/lib/requirements/GeneratedDocumentCounters.psm1 b/.claude/lib/requirements/GeneratedDocumentCounters.psm1 new file mode 100644 index 000000000..f33a2b8d3 --- /dev/null +++ b/.claude/lib/requirements/GeneratedDocumentCounters.psm1 @@ -0,0 +1,44 @@ +<# +.SYNOPSIS + Markdown checkbox counting for generated requirement documents. + +.DESCRIPTION + Provides Get-NamedSectionCheckboxCount, a pure function that counts the + markdown checkbox items contained by a named heading section. It reads no + file, starts no process, and never mutates its input. + CONVENTION: this module fails fast at module scope and imports its siblings with -ErrorAction Stop. +#> + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +<# +.SYNOPSIS +Counts markdown checkbox items contained by a named heading section. +#> +function Get-NamedSectionCheckboxCount { + [CmdletBinding()] + [OutputType([int])] + param( + [Parameter(Mandatory = $true)][AllowEmptyString()][string] $Document, + [Parameter(Mandatory = $true)][string] $Heading + ) + + $headingPattern = '^(?#{1,6})\s+' + [regex]::Escape($Heading) + '\s*$' + $inside = $false + $level = 0 + $count = 0 + foreach ($line in ($Document -split "`r?`n")) { + if (-not $inside) { + $match = [regex]::Match($line, $headingPattern) + if ($match.Success) { $inside = $true; $level = $match.Groups['marks'].Value.Length } + continue + } + $nextHeading = [regex]::Match($line, '^(?#{1,6})\s+') + if ($nextHeading.Success -and $nextHeading.Groups['marks'].Value.Length -le $level) { break } + if ($line -match '^\s*[-*]\s+\[[ xX]\]\s+') { $count++ } + } + return $count +} + +Export-ModuleMember -Function Get-NamedSectionCheckboxCount diff --git a/.claude/settings.json b/.claude/settings.json index 769465396..06d014eed 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -239,6 +239,15 @@ } ] }, + { + "matcher": "prd-feature", + "hooks": [ + { + "type": "command", + "command": "pwsh -NoProfile -File .claude/hooks/validate-prd-feature-output.ps1" + } + ] + }, { "matcher": "pr-author", "hooks": [ diff --git a/.claude/skills/acceptance-criteria-tracking/SKILL.md b/.claude/skills/acceptance-criteria-tracking/SKILL.md index 39a32aa96..3a9b2fbf4 100644 --- a/.claude/skills/acceptance-criteria-tracking/SKILL.md +++ b/.claude/skills/acceptance-criteria-tracking/SKILL.md @@ -35,6 +35,8 @@ When multiple AC source files exist, track checkboxes in **each** applicable fil ## AC Identification +For generated-document summaries, call `.claude/lib/requirements/GeneratedDocumentCounters.psm1` and supply `Acceptance Criteria` as the named section. The counter begins after that heading and ends at the next equal-or-shallower heading. Do not use `scripts/dev_tools/plan_progress_report.py`, which counts plan tasks rather than generated requirements criteria. + Acceptance criteria are markdown checkbox items within AC source files. Deterministic heading rule: diff --git a/.claude/skills/atomic-plan-contract/SKILL.md b/.claude/skills/atomic-plan-contract/SKILL.md index 243fc8629..da573d215 100644 --- a/.claude/skills/atomic-plan-contract/SKILL.md +++ b/.claude/skills/atomic-plan-contract/SKILL.md @@ -155,6 +155,10 @@ Declaration requirement. Every plan handoff MUST carry exactly one of these two ## Preflight Validation (Planner ↔ Executor) +### Planner Internal Review Record + +Before executor preflight, the planner must emit exactly one bounded record between `PLANNER-INTERNAL-REVIEW: PASS` and the existing `PREFLIGHT:` signal. The record requires exactly one passing declaration each for `CITATION-TO-TREE`, `AC-TRACEABILITY`, and `SCOPE-BOUNDARY`; one or more `CITATION: | ` entries; exactly one `AC-INVENTORY:` declaration containing unique nonblank IDs; one `AC-MAPPING: | IMPLEMENTATION: | TESTS: | EVIDENCE: ` for every and only inventory ID; and exactly one `UNRESOLVED-GAPS: NONE`. Missing, blank, duplicate, non-passing, out-of-bounds, or inventory/mapping-disagreeing declarations block handoff. If review cannot pass, emit `SELF-REVIEW: BLOCKED` and do not hand the plan to preflight. `SELF-REVIEW: RE-DERIVED THIS PASS` remains distinct and does not replace executor clearance. + When validating or handing off plans for execution: - Use the directive line: `DIRECTIVE: PREFLIGHT VALIDATION ONLY`. - Require one of the exact signals: diff --git a/.claude/skills/epic-orchestrate/SKILL.md b/.claude/skills/epic-orchestrate/SKILL.md index 473b055fb..55b96900d 100644 --- a/.claude/skills/epic-orchestrate/SKILL.md +++ b/.claude/skills/epic-orchestrate/SKILL.md @@ -292,10 +292,10 @@ Every field needed to re-derive state durably on resume (`worktree_path`, `branc and `gh pr view --json state,mergedAt,headRefOid` — the checkpoint is a cache of that durable state, not the source of truth. -Validate the checkpoint via -`python -m scripts.dev_tools.validate_orchestration_artifacts epic-orchestrator-state --require-complete` -(or the equivalent `mcp__drm-copilot__validate_orchestration_artifacts` call with -`artifact_type: "epic-orchestrator-state"`), implemented in +Validate the checkpoint through the +`mcp__drm-copilot__validate_orchestration_artifacts` call with +`artifact_type: "epic-orchestrator-state"`, supplying the `require_complete` argument on +that same call at the completion gate. The validation is implemented in `scripts/dev_tools/validate_epic_orchestrator_state.py`. ## Completion Requirements diff --git a/.claude/skills/fill-feature-docs/SKILL.md b/.claude/skills/fill-feature-docs/SKILL.md index 6093853c9..ed723f21c 100644 --- a/.claude/skills/fill-feature-docs/SKILL.md +++ b/.claude/skills/fill-feature-docs/SKILL.md @@ -12,6 +12,8 @@ This direct-use wrapper delegates feature-document work to the `prd-feature` wor - Feature folder issue and research context - Existing spec and user-story files when present +For a numeric `spec.md` acceptance criterion, the research context must contain complete `## Numeric Derivation Evidence` with `Complete Family`, `Exhaustive Search Scope`, `Inclusion Rules`, `Exclusion Rules`, `Primary Search Strategy or Query Expression`, `Primary Member Set`, `Primary Count`, `Cross-check Search Strategy or Query Expression`, `Cross-check Member Set`, `Cross-check Count`, and `Member-set Comparison`. The two derivations must be non-empty, independently constructed, distinct in strategy or query expression, exhaustive across the complete family, independently enumerated, and explicitly compared. The worker must withhold a numeric assertion for missing, copied, incomplete, non-exhaustive, narrow, or disagreeing evidence; a single grep, a named-pattern-only query, matching totals, distinct query text, or equal member sets alone does not approve a number. + ## Output Paths - `docs/features/active//spec.md` @@ -20,3 +22,4 @@ This direct-use wrapper delegates feature-document work to the `prd-feature` wor ## Worker Routing - Worker: `prd-feature` +- Require the worker to report the authoritative `research-path` when a numeric acceptance criterion is written. diff --git a/.claude/skills/parallel-add/SKILL.md b/.claude/skills/parallel-add/SKILL.md index 3f41aeb8e..9799ae4ff 100644 --- a/.claude/skills/parallel-add/SKILL.md +++ b/.claude/skills/parallel-add/SKILL.md @@ -21,6 +21,8 @@ this operation may and may not disturb. Read that section before applying anythi ## Prerequisites +- Reject a pending or not-started run with guidance to consolidate the initial set through `/parallel-plan`. Admit exactly one item only after execution has started in an open run; closed runs are not eligible. + - A parallel run is in progress and `artifacts/orchestration/parallel-orchestrator-state.json` tracks its `parallel_slug`. This skill does not start a run; use `/parallel-plan` and `/parallel-run` for that. @@ -59,15 +61,18 @@ re-derivation is mandatory and is not an optimization to skip when the checkpoin 3. **Compute conflict edges over ALL items, including in-flight ones.** Invoke the contention relation `Test-BlastRadiusConflict` from the destination-runtime PowerShell port `.claude/lib/blast-radius/BlastRadius.psm1`, which is published by push-down and needs no Python - interpreter (`Import-Module .claude/lib/blast-radius/BlastRadius.psm1 -Force`). Its two radius - arguments are the two items' radius hashtables, not strings, and the third argument is the - required parsed `config/blast-radius.json` mapping, which push-down publishes into the - destination workspace. `conflicts(a, b, config)` in `scripts/dev_tools/compute_blast_radius.py` - (defined in `scripts/dev_tools/_blast_radius_conflicts.py`) remains the repository authority and - the parity reference. Read the verdict from the conflict key of the returned hashtable. - The hashtable itself is always truthy, so a bare boolean test on the result treats every pair as - conflicting. Map each conflicting pair onto an `(int, int)` conflict edge - of `items[].issue_num` values, normalized so `a < b`. Do not reimplement the relation and do not + interpreter (the default PowerShell 5.1 execution policy blocks `Import-Module` of a `.psm1` + file, so `pwsh` is mandatory: run as `$repoRoot = git rev-parse --show-toplevel; Import-Module + (Join-Path $repoRoot '.claude/lib/blast-radius/BlastRadius.psm1') -Force -ErrorAction Stop`). + Its two radius arguments are the two items' radius hashtables, not strings, and the third + argument is the required parsed `config/blast-radius.json` mapping, which push-down publishes + into the destination workspace. `conflicts(a, b, config)` in + `scripts/dev_tools/compute_blast_radius.py` (defined in + `scripts/dev_tools/_blast_radius_conflicts.py`) remains the repository authority and the parity + reference. Read the verdict from `$result['conflict']`; do not test the returned hashtable + itself, since it is always truthy under PowerShell boolean coercion, so a bare `if ($result)` + check treats every pair as conflicting. Map each conflicting pair onto an `(int, int)` conflict + edge of `items[].issue_num` values, normalized so `a < b`. Do not reimplement the relation and do not compute edges over the unstarted subset only: an in-flight conflict is precisely what the admission decision turns on. diff --git a/.claude/skills/parallel-orchestrate/SKILL.md b/.claude/skills/parallel-orchestrate/SKILL.md index d5bc790c9..eb3e5195a 100644 --- a/.claude/skills/parallel-orchestrate/SKILL.md +++ b/.claude/skills/parallel-orchestrate/SKILL.md @@ -478,9 +478,9 @@ pull-request state, merge time, and merge commit. Where the checkpoint disagrees commands, the commands win and the checkpoint is rewritten from them. Validate through `mcp__drm-copilot__validate_orchestration_artifacts` with -`artifact_type: "parallel-orchestrator-state"`, or the equivalent CLI invocation -`poetry run python -m scripts.dev_tools.validate_orchestration_artifacts parallel-orchestrator-state `, -adding `--require-complete` at the completion gate. +`artifact_type: "parallel-orchestrator-state"`. At the completion gate, pass the +`require_complete` argument on that same MCP call; no repository-local Python +interpreter is required at a destination runtime. ## Completion Requirements diff --git a/.claude/skills/parallel-plan/SKILL.md b/.claude/skills/parallel-plan/SKILL.md index 6535b326b..c226cbc11 100644 --- a/.claude/skills/parallel-plan/SKILL.md +++ b/.claude/skills/parallel-plan/SKILL.md @@ -32,6 +32,8 @@ Before proceeding, `parallel-planner` must: ## Item Intake +Initial intake must provide the complete item set in one `/parallel-plan [ ...]` invocation before waves are calculated. `/parallel-add` is not an initial-intake path. + Invocation shape: `/parallel-plan [ ...]`, where each `` is either a GitHub issue number (already-promoted work) or a potential-entry path (unpromoted work). This is the same intake domain as `/parallel-add`, so initial intake here and F6's add operation accept @@ -180,9 +182,13 @@ PowerShell port** under `.claude/lib/blast-radius/`, which is published by push- Python interpreter: ```powershell -Import-Module .claude/lib/blast-radius/BlastRadius.psm1 -Force +$repoRoot = git rev-parse --show-toplevel +Import-Module (Join-Path $repoRoot '.claude/lib/blast-radius/BlastRadius.psm1') -Force -ErrorAction Stop ``` +The default PowerShell 5.1 execution policy blocks `Import-Module` of a `.psm1` file, so `pwsh` is +mandatory here. + The facade re-exports the five functions this skill needs: `Get-PlanPaths` (port of `extract_plan_paths`), `Get-BlastRadius` (port of `derive_blast_radius`), `Get-BlastRadiusFromObservedPaths` (port of `radius_from_observed_paths`), `Test-BlastRadius` @@ -312,8 +318,9 @@ The library returns the partition; the planner supplies the record fields. emptiness is falsy, while this one returns a hashtable whose emptiness is not expressible at all. 2. Immediately after the conflict-edge set is derived and before anything consumes it, run the lane-assertion diagnostic: - `poetry run python -m scripts.dev_tools.parallel_lane_assertion --manifest docs/features/parallel//parallel.md --edges ": ..."` - (covered by the planner's existing `Bash(poetry run *)` grant). It compares the manifest's + `bash .claude/lib/bash/report-lane-assertion.sh --manifest docs/features/parallel//parallel.md --edges ": ..."` + (a bash invocation against the published payload; it requires no Python interpreter at the + destination runtime). It compares the manifest's optional `expected_conflict_components` assertion (invariant M8) against the connected components of the DERIVED conflict graph and prints one `ADVISORY` line per finding in four classes: expected-together-but-derived-apart, expected-apart-but-derived-together, a member diff --git a/.claude/skills/remediation-handoff-atomic-planner/SKILL.md b/.claude/skills/remediation-handoff-atomic-planner/SKILL.md index 1e944becf..a6a0db7d0 100644 --- a/.claude/skills/remediation-handoff-atomic-planner/SKILL.md +++ b/.claude/skills/remediation-handoff-atomic-planner/SKILL.md @@ -106,6 +106,8 @@ After the plan is authored, `atomic-executor` runs preflight under the directive The orchestrator records the preflight outcome in `remediation_loop.cycles[current_cycle].preflight` with `iterations` (counter) and `final_status` (`clear|changes_requested|pending`). +For a well-scoped item where `preflight.iterations > 1`, record a process-defect investigation that identifies the incomplete planner internal-review dimension. Treat excess rounds as a process signal, not routine iteration. + The exhaustive-pass, defect-enumeration, and delta-self-check rules that govern how `atomic-executor` conducts preflight are defined in the `## Preflight Validation (Planner ↔ Executor)` section of `.claude/skills/atomic-plan-contract/SKILL.md` and are not restated here. Alongside `iterations` and `final_status`, the orchestrator also records in `remediation_loop.cycles[current_cycle].preflight` the convergence line `atomic-executor` returned on that round, which is one of `CONVERGENCE: NO FURTHER ROUNDS EXPECTED` or `CONVERGENCE: FURTHER ROUNDS LIKELY`. This convergence field extends the field set already recorded at `remediation_loop.cycles[current_cycle].preflight` rather than replacing it: `iterations` and `final_status` continue to be recorded exactly as stated above, and the convergence field is written in addition to them. diff --git a/.claude/skills/research-issue/SKILL.md b/.claude/skills/research-issue/SKILL.md index c7c0c5576..c3821072c 100644 --- a/.claude/skills/research-issue/SKILL.md +++ b/.claude/skills/research-issue/SKILL.md @@ -55,6 +55,8 @@ Create or update a single research file at one of the two tracked research roots - Map acceptance criteria into a concrete design. - Propose state model, transitions, internal API boundaries, and required file changes. +- For every numeric count, enumeration, or population proposed for an approved `spec.md` acceptance criterion, add complete `## Numeric Derivation Evidence`. Each record must identify `Complete Family`, `Exhaustive Search Scope`, `Inclusion Rules`, `Exclusion Rules`, `Primary Search Strategy or Query Expression`, `Primary Member Set`, `Primary Count`, `Cross-check Search Strategy or Query Expression`, `Cross-check Member Set`, `Cross-check Count`, and `Member-set Comparison`. +- Both derivations must be non-empty and independently constructed. They must use distinct search strategies or query expressions, independently enumerate the member sets, and explicitly compare normalized member sets. The scope must cover the entire declared family, including all relevant overloads and members; reject a single grep, a narrow named-pattern search, or a query that covers only one family member even when totals and member sets appear equal. Withhold the numeric assertion if the records are incomplete, duplicated, non-exhaustive, narrow, or disagree. ### 5. Testing Implications @@ -67,3 +69,4 @@ Create or update a single research file at one of the two tracked research roots - Ground all findings in verified evidence from the codebase and authoritative external sources. - Keep discussion of non-selected approaches brief. - Do not claim or perform nested worker delegation. +- Omit numeric acceptance-criterion facts when the numeric derivation record is absent, incomplete, or disagrees. diff --git a/.codex/config.toml b/.codex/config.toml index e46e186c9..92dfa8502 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.1.5"] +args = ["-y", "@danmoisan/drm-copilot-mcp@1.1.8"] required = true enabled_tools = [ "collect_commit_context", diff --git a/.gitignore b/.gitignore index 8b1aee3a9..ca9949ee7 100644 --- a/.gitignore +++ b/.gitignore @@ -355,3 +355,8 @@ logs/ # .claude/state/powershell-batch-budget..json. It is session-scoped and records # absolute local paths, so it must never be committed. .claude/state/ + +# BEGIN drm-copilot managed ignores +.claude/state/ +.codex/state/ +# END drm-copilot managed ignores diff --git a/config/orchestration-routing.json b/config/orchestration-routing.json index 6b57f7ece..938d210e8 100644 --- a/config/orchestration-routing.json +++ b/config/orchestration-routing.json @@ -250,22 +250,22 @@ "epic_preparation_child", "epic_execution_child" ], - "forced_root_personas": [ - "epic-planner", - "epic-orchestrator" - ], - "parallelism": { - "default_max_parallel_features": 4, - "hard_max_parallel_features": 8 - }, - "escalation_precedence": [ + "forced_root_personas": [ + "epic-planner", + "epic-orchestrator" + ], + "parallelism": { + "default_max_parallel_features": 4, + "hard_max_parallel_features": 8 + }, + "escalation_precedence": [ "epic_child_context", "invalid_estimate", "cross_language", "unsupported_language", "cross_cutting", - "direct_mode_disabled", - "production_budget_exceeded" + "direct_mode_disabled", + "production_budget_exceeded" ], "receipt_key": "codex_topology_receipts" }, @@ -308,7 +308,7 @@ "model_reasoning_effort": "max" } }, - "c3_elevated_profile": { + "c3_elevated_profile": { "suffix": "c3-elevated", "model": "gpt-5.6-sol", "model_reasoning_effort": "high", @@ -320,13 +320,13 @@ ], "orchestration_complexity_ceiling": "C4" } - }, - "ceiling_transition_policy": { - "monotonic": true, - "receipt_key": "ceiling_transition", - "affected_delegation_ids_required_on_increase": true - }, - "forced_personas": { + }, + "ceiling_transition_policy": { + "monotonic": true, + "receipt_key": "ceiling_transition", + "affected_delegation_ids_required_on_increase": true + }, + "forced_personas": { "epic-planner": { "suffix": "", "model": "gpt-5.6-sol",